Skip to content

Commit e926bf7

Browse files
infiniteflowerbfullamdavibrocOGPoyraz
authored
feat: unified swaps (#16433)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> This PR handles Unified Swaps routing, network, and token selection. It's hidden behind an env var and a feature flag, so there is no risk of accidentally exposing this feature before it's ready. ## **Related issues** Resolves: https://consensyssoftware.atlassian.net/browse/MMS-2341 ## **Manual testing steps** Setup 1. Add `export MM_UNIFIED_SWAPS_ENABLED="true"` to `.js.env` 2. Terminal: `source .js.env` 3. Start the app up Swaps 5. Go to Swaps (notice there's no Bridge button anymore) 6. Request a quote for a source and dest token with the same chain Bridge 5. Go to Swaps (notice there's no Bridge button anymore) 6. Request a quote for a source and dest token with a different chain Feature flags 1. Add `export MM_UNIFIED_SWAPS_ENABLED="false"` to `.js.env` 2. Terminal: `source .js.env` 3. Start the app up 4. You should see 2 buttons for Swap and Bridge 5. Swap btn should take you to Legacy Swaps 6. Bridge btn should take you to Bridge ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> Bridge https://github.com/user-attachments/assets/eb597e89-8e98-4413-a006-f17a5428e1c2 Swap https://github.com/user-attachments/assets/1c7dd607-7653-4359-bd5a-53d528ba5b5c ## **Pre-merge author checklist** - [x] 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). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] 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. ## **Pre-merge reviewer checklist** - [ ] 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. --------- Co-authored-by: Bryan Fullam <bryan.fullam@consensys.net> Co-authored-by: Davide Brocchetto <davide.brocchetto@consensys.net> Co-authored-by: OGPoyraz <omergoktugpoyraz@gmail.com>
1 parent 986898d commit e926bf7

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+1021
-679
lines changed

.js.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ export OVERRIDE_REMOTE_FEATURE_FLAGS="false"
126126
export BRIDGE_USE_DEV_APIS="false"
127127

128128
export MM_BRIDGE_ENABLED="true"
129+
export MM_UNIFIED_SWAPS_ENABLED="true"
129130

130131
# Set ramps environment to staging.
131132
# This is required when developing or checking existing ramps tests locally.

app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -286,9 +286,6 @@ describe('BridgeView', () => {
286286
// Verify navigation to BridgeTokenSelector
287287
expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, {
288288
screen: Routes.BRIDGE.MODALS.SOURCE_TOKEN_SELECTOR,
289-
params: {
290-
bridgeViewMode: BridgeViewMode.Bridge,
291-
},
292289
});
293290
});
294291

@@ -901,7 +898,7 @@ describe('BridgeView', () => {
901898

902899
describe('handleContinue - Blockaid Validation', () => {
903900
const mockQuote = mockQuotes[0] as unknown as QuoteResponse;
904-
901+
905902
beforeEach(() => {
906903
jest.clearAllMocks();
907904
mockValidateBridgeTx.mockResolvedValue({
@@ -916,10 +913,10 @@ describe('BridgeView', () => {
916913
it('should navigate to blockaid modal on validation error for Solana swap', async () => {
917914
// Mock validation result with validation error
918915
mockValidateBridgeTx.mockResolvedValue({
919-
result: {
920-
validation: {
921-
reason: 'Transaction may result in loss of funds'
922-
}
916+
result: {
917+
validation: {
918+
reason: 'Transaction may result in loss of funds'
919+
}
923920
},
924921
error: null,
925922
});
@@ -994,10 +991,10 @@ describe('BridgeView', () => {
994991
it('should navigate to blockaid modal on simulation error for Solana to EVM bridge', async () => {
995992
// Mock validation result with simulation error
996993
mockValidateBridgeTx.mockResolvedValue({
997-
result: {
998-
validation: {
999-
reason: null
1000-
}
994+
result: {
995+
validation: {
996+
reason: null
997+
}
1001998
},
1002999
error: 'Simulation failed',
10031000
});
@@ -1072,10 +1069,10 @@ describe('BridgeView', () => {
10721069
it('should prioritize validation error over simulation error', async () => {
10731070
// Mock validation result with both validation and simulation errors
10741071
mockValidateBridgeTx.mockResolvedValue({
1075-
result: {
1076-
validation: {
1077-
reason: 'Transaction may result in loss of funds'
1078-
}
1072+
result: {
1073+
validation: {
1074+
reason: 'Transaction may result in loss of funds'
1075+
}
10791076
},
10801077
error: 'Simulation failed',
10811078
});
@@ -1144,10 +1141,10 @@ describe('BridgeView', () => {
11441141
it('should proceed with transaction when no validation errors', async () => {
11451142
// Mock validation result with no errors
11461143
mockValidateBridgeTx.mockResolvedValue({
1147-
result: {
1148-
validation: {
1149-
reason: null
1150-
}
1144+
result: {
1145+
validation: {
1146+
reason: null
1147+
}
11511148
},
11521149
error: null,
11531150
});

app/components/UI/Bridge/Views/BridgeView/index.tsx

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
selectIsSolanaToEvm,
4141
selectDestAddress,
4242
selectIsSolanaSourced,
43+
selectBridgeViewMode,
4344
} from '../../../../../core/redux/slices/bridge';
4445
import {
4546
useNavigation,
@@ -62,8 +63,6 @@ import { BannerAlertSeverity } from '../../../../../component-library/components
6263
import { createStyles } from './BridgeView.styles';
6364
import { useInitialSourceToken } from '../../hooks/useInitialSourceToken';
6465
import { useInitialDestToken } from '../../hooks/useInitialDestToken';
65-
import type { BridgeSourceTokenSelectorRouteParams } from '../../components/BridgeSourceTokenSelector';
66-
import type { BridgeDestTokenSelectorRouteParams } from '../../components/BridgeDestTokenSelector';
6766
import { useGasFeeEstimates } from '../../../../Views/confirmations/hooks/gas/useGasFeeEstimates';
6867
import { selectSelectedNetworkClientId } from '../../../../../selectors/networkController';
6968
import { useMetrics, MetaMetricsEvents } from '../../../../hooks/useMetrics';
@@ -80,7 +79,6 @@ import { endTrace, TraceName } from '../../../../../util/trace.ts';
8079
export interface BridgeRouteParams {
8180
token?: BridgeToken;
8281
sourcePage: string;
83-
bridgeViewMode: BridgeViewMode;
8482
}
8583

8684
const BridgeView = () => {
@@ -106,6 +104,7 @@ const BridgeView = () => {
106104
const destToken = useSelector(selectDestToken);
107105
const destChainId = useSelector(selectSelectedDestChainId);
108106
const destAddress = useSelector(selectDestAddress);
107+
const bridgeViewMode = useSelector(selectBridgeViewMode);
109108
const {
110109
activeQuote,
111110
isLoading,
@@ -220,8 +219,8 @@ const BridgeView = () => {
220219
);
221220

222221
useEffect(() => {
223-
navigation.setOptions(getBridgeNavbar(navigation, route, colors));
224-
}, [navigation, route, colors]);
222+
navigation.setOptions(getBridgeNavbar(navigation, bridgeViewMode, colors));
223+
}, [navigation, bridgeViewMode, colors]);
225224

226225
const hasTrackedPageView = useRef(false);
227226
useEffect(() => {
@@ -231,7 +230,7 @@ const BridgeView = () => {
231230
hasTrackedPageView.current = true;
232231
trackEvent(
233232
createEventBuilder(
234-
route.params.bridgeViewMode === BridgeViewMode.Bridge
233+
bridgeViewMode === BridgeViewMode.Bridge
235234
? MetaMetricsEvents.BRIDGE_PAGE_VIEWED
236235
: MetaMetricsEvents.SWAP_PAGE_VIEWED,
237236
)
@@ -251,7 +250,7 @@ const BridgeView = () => {
251250
destToken,
252251
trackEvent,
253252
createEventBuilder,
254-
route.params.bridgeViewMode,
253+
bridgeViewMode,
255254
]);
256255

257256
// Update isErrorBannerVisible when input focus changes
@@ -326,24 +325,18 @@ const BridgeView = () => {
326325
const handleSourceTokenPress = () =>
327326
navigation.navigate(Routes.BRIDGE.MODALS.ROOT, {
328327
screen: Routes.BRIDGE.MODALS.SOURCE_TOKEN_SELECTOR,
329-
params: {
330-
bridgeViewMode: route.params.bridgeViewMode,
331-
} as BridgeSourceTokenSelectorRouteParams,
332328
});
333329

334330
const handleDestTokenPress = () =>
335331
navigation.navigate(Routes.BRIDGE.MODALS.ROOT, {
336332
screen: Routes.BRIDGE.MODALS.DEST_TOKEN_SELECTOR,
337-
params: {
338-
bridgeViewMode: route.params.bridgeViewMode,
339-
} as BridgeDestTokenSelectorRouteParams,
340333
});
341334

342335
const getButtonLabel = () => {
343336
if (hasInsufficientBalance) return strings('bridge.insufficient_funds');
344337
if (isSubmittingTx) return strings('bridge.submitting_transaction');
345338

346-
const isSwap = route.params.bridgeViewMode === BridgeViewMode.Swap;
339+
const isSwap = sourceToken?.chainId === destToken?.chainId;
347340
return isSwap
348341
? strings('bridge.confirm_swap')
349342
: strings('bridge.confirm_bridge');

app/components/UI/Bridge/components/BridgeDestNetworkSelector/index.tsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useNavigation, useRoute , RouteProp } from '@react-navigation/native';
55
import { Box } from '../../../Box/Box';
66
import { useStyles } from '../../../../../component-library/hooks';
77
import {
8+
selectBridgeViewMode,
89
selectEnabledDestChains,
910
setSelectedDestChainId,
1011
} from '../../../../../core/redux/slices/bridge';
@@ -16,6 +17,7 @@ import { NetworkRow } from '../NetworkRow';
1617
import Routes from '../../../../../constants/navigation/Routes';
1718
import { selectChainId } from '../../../../../selectors/networkController';
1819
import { BridgeViewMode } from '../../types';
20+
1921
export interface BridgeDestNetworkSelectorRouteParams {
2022
shouldGoToTokens?: boolean;
2123
}
@@ -33,24 +35,27 @@ export const BridgeDestNetworkSelector: React.FC = () => {
3335
const dispatch = useDispatch();
3436
const enabledDestChains = useSelector(selectEnabledDestChains);
3537
const currentChainId = useSelector(selectChainId);
38+
const bridgeViewMode = useSelector(selectBridgeViewMode);
3639

3740
const handleChainSelect = useCallback((chainId: Hex | CaipChainId) => {
3841
dispatch(setSelectedDestChainId(chainId));
3942

4043
navigation.goBack();
4144

42-
if (route.params.shouldGoToTokens) {
45+
if (route?.params?.shouldGoToTokens) {
4346
navigation.navigate(Routes.BRIDGE.MODALS.ROOT, {
4447
screen: Routes.BRIDGE.MODALS.DEST_TOKEN_SELECTOR,
45-
params: {
46-
bridgeViewMode: BridgeViewMode.Bridge,
47-
},
4848
});
4949
}
50-
}, [dispatch, navigation, route.params.shouldGoToTokens]);
50+
}, [dispatch, navigation, route?.params?.shouldGoToTokens]);
5151

5252
const renderDestChains = useCallback(() => (
53-
enabledDestChains.filter(chain => chain.chainId !== currentChainId).map((chain) => (
53+
enabledDestChains.filter(chain => {
54+
if (bridgeViewMode === BridgeViewMode.Unified) {
55+
return true;
56+
}
57+
return chain.chainId !== currentChainId;
58+
}).map((chain) => (
5459
<TouchableOpacity
5560
key={chain.chainId}
5661
onPress={() => handleChainSelect(chain.chainId)}
@@ -65,7 +70,7 @@ export const BridgeDestNetworkSelector: React.FC = () => {
6570
</ListItem>
6671
</TouchableOpacity>
6772
))
68-
), [enabledDestChains, handleChainSelect, currentChainId]);
73+
), [enabledDestChains, handleChainSelect, currentChainId, bridgeViewMode]);
6974

7075
return (
7176
<BridgeNetworkSelectorBase>

app/components/UI/Bridge/components/BridgeDestNetworksBar.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { StyleSheet } from 'react-native';
1212
import { IconName } from '../../../../component-library/components/Icons/Icon';
1313
import { useDispatch, useSelector } from 'react-redux';
1414
import {
15+
selectBridgeViewMode,
1516
selectEnabledDestChains,
1617
selectSelectedDestChainId,
1718
setSelectedDestChainId,
@@ -39,6 +40,7 @@ import { selectChainId } from '../../../../selectors/networkController';
3940
import { ScrollView } from 'react-native-gesture-handler';
4041
///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps)
4142
import { SolScope } from '@metamask/keyring-api';
43+
import { BridgeViewMode } from '../types';
4244
///: END:ONLY_INCLUDE_IF
4345
const createStyles = (params: { theme: Theme }) => {
4446
const { theme } = params;
@@ -94,17 +96,23 @@ export const BridgeDestNetworksBar = () => {
9496
const selectedDestChainId = useSelector(selectSelectedDestChainId);
9597
const currentChainId = useSelector(selectChainId);
9698
const { styles } = useStyles(createStyles, { selectedDestChainId });
99+
const bridgeViewMode = useSelector(selectBridgeViewMode);
97100

98101
const sortedDestChains = useMemo(
99102
() =>
100103
[...enabledDestChains]
101-
.filter((chain) => chain.chainId !== currentChainId)
104+
.filter((chain) => {
105+
if (bridgeViewMode === BridgeViewMode.Unified) {
106+
return true;
107+
}
108+
return chain.chainId !== currentChainId;
109+
})
102110
.sort((a, b) => {
103111
const aPopularity = ChainPopularity[a.chainId] ?? Infinity;
104112
const bPopularity = ChainPopularity[b.chainId] ?? Infinity;
105113
return aPopularity - bPopularity;
106114
}),
107-
[enabledDestChains, currentChainId],
115+
[enabledDestChains, currentChainId, bridgeViewMode],
108116
);
109117

110118
const navigateToNetworkSelector = () => {

app/components/UI/Bridge/components/BridgeDestTokenSelector/BridgeDestTokenSelector.test.tsx

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import { fireEvent, waitFor } from '@testing-library/react-native';
66
import { renderScreen } from '../../../../../util/test/renderWithProvider';
77
import { BridgeDestTokenSelector } from '.';
88
import Routes from '../../../../../constants/navigation/Routes';
9-
import { setDestToken } from '../../../../../core/redux/slices/bridge';
9+
import { selectBridgeViewMode, setDestToken } from '../../../../../core/redux/slices/bridge';
1010
import { cloneDeep } from 'lodash';
11-
import { useRoute } from '@react-navigation/native';
11+
import { BridgeViewMode } from '../../types';
1212

1313
const mockNavigate = jest.fn();
1414
const mockGoBack = jest.fn();
@@ -19,11 +19,6 @@ jest.mock('@react-navigation/native', () => ({
1919
navigate: mockNavigate,
2020
goBack: mockGoBack,
2121
}),
22-
useRoute: jest.fn().mockReturnValue({
23-
params: {
24-
bridgeViewMode: 'Bridge',
25-
},
26-
}),
2722
}));
2823

2924
jest.mock('../../../../../core/redux/slices/bridge', () => {
@@ -33,6 +28,7 @@ jest.mock('../../../../../core/redux/slices/bridge', () => {
3328
...actual,
3429
default: actual.default,
3530
setDestToken: jest.fn(actual.setDestToken),
31+
selectBridgeViewMode: jest.fn().mockReturnValue('Bridge'),
3632
};
3733
});
3834

@@ -248,11 +244,7 @@ describe('BridgeDestTokenSelector', () => {
248244
});
249245

250246
it('hides destination network bar when mode is Swap', async () => {
251-
(useRoute as jest.Mock).mockReturnValue({
252-
params: {
253-
bridgeViewMode: 'Swap',
254-
},
255-
});
247+
(selectBridgeViewMode as unknown as jest.Mock).mockReturnValue(BridgeViewMode.Swap);
256248
const { queryByText } = renderScreen(
257249
BridgeDestTokenSelector,
258250
{
@@ -265,11 +257,7 @@ describe('BridgeDestTokenSelector', () => {
265257
expect(seeAllButton).toBeNull();
266258

267259
// Restore the original mock
268-
(useRoute as jest.Mock).mockReturnValue({
269-
params: {
270-
bridgeViewMode: 'Bridge',
271-
},
272-
});
260+
(selectBridgeViewMode as unknown as jest.Mock).mockReturnValue(BridgeViewMode.Bridge);
273261
});
274262

275263
it('shows destination network bar when mode is Bridge', async () => {

app/components/UI/Bridge/components/BridgeDestTokenSelector/__snapshots__/BridgeDestTokenSelector.test.tsx.snap

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -866,7 +866,7 @@ exports[`BridgeDestTokenSelector renders with initial state and displays tokens
866866
"paddingVertical": 10,
867867
}
868868
}
869-
testID="asset-HELLO"
869+
testID="asset-0x1-HELLO"
870870
>
871871
<View
872872
alignItems="center"
@@ -1158,7 +1158,7 @@ exports[`BridgeDestTokenSelector renders with initial state and displays tokens
11581158
"paddingVertical": 10,
11591159
}
11601160
}
1161-
testID="asset-TOKEN1"
1161+
testID="asset-0x1-TOKEN1"
11621162
>
11631163
<View
11641164
alignItems="center"
@@ -1450,7 +1450,7 @@ exports[`BridgeDestTokenSelector renders with initial state and displays tokens
14501450
"paddingVertical": 10,
14511451
}
14521452
}
1453-
testID="asset-ETH"
1453+
testID="asset-0x1-ETH"
14541454
>
14551455
<View
14561456
alignItems="center"

0 commit comments

Comments
 (0)