diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 3edbae370cb..f64a6ef5215 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -42,6 +42,7 @@ app/core/Engine/types.ts @MetaMask/mobile-pla
app/core/Engine/controllers/remote-feature-flag-controller/ @MetaMask/mobile-platform
app/core/DeeplinkManager @MetaMask/mobile-platform
scripts/build.sh @MetaMask/mobile-platform
+scripts/update-expo-channel.js @MetaMask/mobile-admins
# Platform & Snaps Code Fencing File
metro.transform.js @MetaMask/mobile-platform @MetaMask/core-platform
diff --git a/.js.env.example b/.js.env.example
index 9b60cf2789a..80a050df90d 100644
--- a/.js.env.example
+++ b/.js.env.example
@@ -44,6 +44,9 @@ export WALLET_CONNECT_PROJECT_ID=""
# Default PORT for metro
export WATCHER_PORT=8081
+# Expo Project ID for OTA updates
+export EXPO_PROJECT_ID=""
+
# Environment: "production", "pre-release" or "dev"
export METAMASK_ENVIRONMENT="dev"
diff --git a/app.config.js b/app.config.js
index 8c4a31c76b4..57570311150 100644
--- a/app.config.js
+++ b/app.config.js
@@ -1,3 +1,5 @@
+const { RUNTIME_VERSION, PROJECT_ID, UPDATE_URL } = require('./ota.config.js');
+
module.exports = {
name: 'MetaMask',
displayName: 'MetaMask',
@@ -15,7 +17,9 @@ module.exports = {
'../../node_modules/@notifee/react-native/android/libs',
],
},
- ios: {},
+ ios: {
+ jsEngine: 'hermes',
+ },
},
],
[
@@ -36,5 +40,28 @@ module.exports = {
ios: {
bundleIdentifier: 'io.metamask.MetaMask',
usesAppleSignIn: true,
+ jsEngine: 'hermes',
+ },
+ expo: {
+ owner: 'metamask-test',
+ runtimeVersion: RUNTIME_VERSION,
+ updates: {
+ url: UPDATE_URL,
+ // Channel is set by requestHeaders, will be overridden with build script
+ requestHeaders: {
+ 'expo-channel-name': 'preview',
+ },
+ },
+ extra: {
+ eas: {
+ projectId: PROJECT_ID,
+ },
+ },
+ android: {
+ package: 'io.metamask',
+ },
+ ios: {
+ bundleIdentifier: 'io.metamask.MetaMask',
+ },
},
};
diff --git a/app/__mocks__/expo-updates.ts b/app/__mocks__/expo-updates.ts
new file mode 100644
index 00000000000..ca5c0d9c3cc
--- /dev/null
+++ b/app/__mocks__/expo-updates.ts
@@ -0,0 +1,34 @@
+// Mock for expo-updates module
+export const channel = 'test-channel';
+export const runtimeVersion = '1.0.0';
+export const isEmbeddedLaunch = true;
+export const isEnabled = true;
+
+export const checkForUpdateAsync = jest.fn();
+export const fetchUpdateAsync = jest.fn();
+export const reloadAsync = jest.fn();
+export const useUpdates = jest.fn();
+
+export const UpdateEventType = {
+ ERROR: 'error',
+ NO_UPDATE_AVAILABLE: 'noUpdateAvailable',
+ UPDATE_AVAILABLE: 'updateAvailable',
+};
+
+export const UpdateCheckResult = {
+ isAvailable: false,
+ manifest: null,
+};
+
+export default {
+ channel,
+ runtimeVersion,
+ isEmbeddedLaunch,
+ isEnabled,
+ checkForUpdateAsync,
+ fetchUpdateAsync,
+ reloadAsync,
+ useUpdates,
+ UpdateEventType,
+ UpdateCheckResult,
+};
diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js
index 9904e414231..0fa1e02dbf3 100644
--- a/app/components/UI/Navbar/index.js
+++ b/app/components/UI/Navbar/index.js
@@ -2041,105 +2041,13 @@ export function getDepositNavbarOptions(
navigation.dangerouslyGetParent()?.pop();
onClose?.();
}}
+ testID="deposit-close-navbar-button"
/>
)
: null,
};
}
-export function getFiatOnRampAggNavbar(
- navigation,
- { title = '', showBack = true, showCancel = true, showNetwork = false } = {},
- themeColors,
- onCancel,
-) {
- const innerStyles = StyleSheet.create({
- headerButtonText: {
- color: themeColors.primary.default,
- fontSize: scale(11),
- ...fontStyles.normal,
- },
- headerStyle: {
- backgroundColor: themeColors.background.default,
- shadowColor: importedColors.transparent,
- elevation: 0,
- },
- headerTitleStyle: {
- fontSize: 18,
- ...fontStyles.normal,
- color: themeColors.text.default,
- ...(!showBack && { textAlign: 'center' }),
- },
- });
-
- const leftActionText = strings('navigation.back');
-
- const leftAction = () => navigation.pop();
-
- const navigationCancelText = strings('navigation.cancel');
-
- const disableNetwork = !showNetwork;
- const showSelectedNetwork = showNetwork;
-
- return {
- headerTitle: () => (
-
- ),
- headerLeft: () => {
- if (!showBack) return ;
-
- return Device.isAndroid() ? (
-
-
-
- ) : (
-
- {leftActionText}
-
- );
- },
- headerRight: () => {
- if (!showCancel) return ;
- return (
- {
- navigation.dangerouslyGetParent()?.pop();
- onCancel?.();
- }}
- style={styles.closeButton}
- accessibilityRole="button"
- accessible
- >
-
- {navigationCancelText}
-
-
- );
- },
- headerStyle: innerStyles.headerStyle,
- headerTitleStyle: innerStyles.headerTitleStyle,
- };
-}
-
export const getEditAccountNameNavBarOptions = (goBack, themeColors) => {
const innerStyles = StyleSheet.create({
headerStyle: {
diff --git a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.test.tsx b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.test.tsx
index 39da5070297..11a081dc46e 100644
--- a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.test.tsx
+++ b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.test.tsx
@@ -430,9 +430,9 @@ describe('BuildQuote View', () => {
it('navigates and tracks event on cancel button press', async () => {
render(BuildQuote);
- fireEvent.press(screen.getByRole('button', { name: 'Cancel' }));
+ fireEvent.press(screen.getByTestId('deposit-close-navbar-button'));
expect(mockPop).toHaveBeenCalled();
- expect(mockTrackEvent).toBeCalledWith('ONRAMP_CANCELED', {
+ expect(mockTrackEvent).toHaveBeenCalledWith('ONRAMP_CANCELED', {
chain_id_destination: '1',
location: 'Amount to Buy Screen',
});
@@ -444,9 +444,9 @@ describe('BuildQuote View', () => {
mockUseRampSDKValues.isSell = true;
mockUseRampSDKValues.rampType = RampType.SELL;
render(BuildQuote);
- fireEvent.press(screen.getByRole('button', { name: 'Cancel' }));
+ fireEvent.press(screen.getByTestId('deposit-close-navbar-button'));
expect(mockPop).toHaveBeenCalled();
- expect(mockTrackEvent).toBeCalledWith('OFFRAMP_CANCELED', {
+ expect(mockTrackEvent).toHaveBeenCalledWith('OFFRAMP_CANCELED', {
chain_id_source: '1',
location: 'Amount to Sell Screen',
});
diff --git a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx
index 644b7065880..7613c0bfa11 100644
--- a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx
+++ b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx
@@ -52,7 +52,7 @@ import BadgeWrapper, {
import BadgeNetwork from '../../../../../../component-library/components/Badges/Badge/variants/BadgeNetwork';
import { NATIVE_ADDRESS } from '../../../../../../constants/on-ramp';
-import { getFiatOnRampAggNavbar } from '../../../../Navbar';
+import { getDepositNavbarOptions } from '../../../../Navbar';
import { strings } from '../../../../../../../locales/i18n';
import {
createNavigationDetails,
@@ -115,10 +115,8 @@ export const createBuildQuoteNavDetails =
const BuildQuote = () => {
const navigation = useNavigation();
const params = useParams();
- const {
- styles,
- theme: { colors, themeAppearance },
- } = useStyles(styleSheet, {});
+ const { styles, theme } = useStyles(styleSheet, {});
+ const { colors, themeAppearance } = theme;
const trackEvent = useAnalytics();
const [amountFocused, setAmountFocused] = useState(false);
const [amount, setAmount] = useState('0');
@@ -448,20 +446,19 @@ const BuildQuote = () => {
useEffect(() => {
navigation.setOptions(
- getFiatOnRampAggNavbar(
+ getDepositNavbarOptions(
navigation,
{
title: isBuy
? strings('fiat_on_ramp_aggregator.amount_to_buy')
: strings('fiat_on_ramp_aggregator.amount_to_sell'),
showBack: params.showBack,
- showNetwork: false,
},
- colors,
+ theme,
handleCancelPress,
),
);
- }, [navigation, colors, handleCancelPress, params.showBack, isBuy]);
+ }, [navigation, theme, handleCancelPress, params.showBack, isBuy]);
/**
* * Keypad style, handlers and effects
diff --git a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap
index 7abea18246b..93ec78c967a 100644
--- a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap
+++ b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap
@@ -58,12 +58,12 @@ exports[`BuildQuote View Balance display displays balance from useBalance for no
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -116,29 +116,87 @@ exports[`BuildQuote View Balance display displays balance from useBalance for no
}
}
>
-
-
- Back
-
-
+
+
+
-
-
- Cancel
-
-
+
+
+
@@ -2801,12 +2917,12 @@ exports[`BuildQuote View Crypto Currency Data renders a special error page if cr
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -2859,29 +2975,87 @@ exports[`BuildQuote View Crypto Currency Data renders a special error page if cr
}
}
>
-
-
- Back
-
-
+
+
+
-
-
- Cancel
-
-
+
+
+
@@ -3445,12 +3677,12 @@ exports[`BuildQuote View Crypto Currency Data renders a special error page if cr
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -3503,29 +3735,87 @@ exports[`BuildQuote View Crypto Currency Data renders a special error page if cr
}
}
>
-
-
- Back
-
-
+
+
+
-
-
- Cancel
-
-
+
+
+
@@ -4089,12 +4437,12 @@ exports[`BuildQuote View Crypto Currency Data renders an error page when there i
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -4147,29 +4495,87 @@ exports[`BuildQuote View Crypto Currency Data renders an error page when there i
}
}
>
-
-
- Back
-
-
+
+
+
-
-
- Cancel
-
-
+
+
+
@@ -4733,12 +5197,12 @@ exports[`BuildQuote View Crypto Currency Data renders the loading page when cryp
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -4791,29 +5255,87 @@ exports[`BuildQuote View Crypto Currency Data renders the loading page when cryp
}
}
>
-
-
- Back
-
-
+
+
+
-
-
- Cancel
-
-
+
+
+
@@ -7180,12 +7760,12 @@ exports[`BuildQuote View Fiat Currency Data renders an error page when there is
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -7238,29 +7818,87 @@ exports[`BuildQuote View Fiat Currency Data renders an error page when there is
}
}
>
-
-
- Back
-
-
+
+
+
-
-
- Cancel
-
-
+
+
+
@@ -7824,12 +8520,12 @@ exports[`BuildQuote View Fiat Currency Data renders the loading page when fiats
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -7882,29 +8578,87 @@ exports[`BuildQuote View Fiat Currency Data renders the loading page when fiats
}
}
>
-
-
- Back
-
-
+
+
+
-
-
- Cancel
-
-
+
+
+
@@ -10180,12 +10992,12 @@ exports[`BuildQuote View Payment Method Data renders an error page when there is
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -10238,29 +11050,87 @@ exports[`BuildQuote View Payment Method Data renders an error page when there is
}
}
>
-
-
- Back
-
-
+
+
+
-
-
- Cancel
-
-
+
+
+
@@ -10824,12 +11752,12 @@ exports[`BuildQuote View Payment Method Data renders no icons if there are no pa
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -10882,29 +11810,87 @@ exports[`BuildQuote View Payment Method Data renders no icons if there are no pa
}
}
>
-
-
- Back
-
-
+
+
+
-
-
- Cancel
-
-
+
+
+
@@ -13524,12 +14568,12 @@ exports[`BuildQuote View Payment Method Data renders the loading page when payme
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -13582,29 +14626,87 @@ exports[`BuildQuote View Payment Method Data renders the loading page when payme
}
}
>
-
-
- Back
-
-
+
+
+
-
-
- Cancel
-
-
+
+
+
@@ -16146,12 +17306,12 @@ exports[`BuildQuote View Regions data renders an error page when there is a regi
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -16204,29 +17364,87 @@ exports[`BuildQuote View Regions data renders an error page when there is a regi
}
}
>
-
-
- Back
-
-
+
+
+
-
-
- Cancel
-
-
+
+
+
@@ -16790,12 +18066,12 @@ exports[`BuildQuote View Regions data renders the loading page when regions are
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -16848,29 +18124,87 @@ exports[`BuildQuote View Regions data renders the loading page when regions are
}
}
>
-
-
- Back
-
-
+
+
+
-
-
- Cancel
-
-
+
+
+
@@ -19110,12 +20502,12 @@ exports[`BuildQuote View renders correctly 1`] = `
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -19168,29 +20560,87 @@ exports[`BuildQuote View renders correctly 1`] = `
}
}
>
-
-
- Back
-
-
+
+
+
-
-
- Cancel
-
-
+
+
+
@@ -21853,12 +23361,12 @@ exports[`BuildQuote View renders correctly 2`] = `
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -21911,29 +23419,87 @@ exports[`BuildQuote View renders correctly 2`] = `
}
}
>
-
-
- Back
-
-
+
+
+
-
-
- Cancel
-
-
+
+
+
@@ -24576,12 +26200,12 @@ exports[`BuildQuote View renders correctly when sdkError is present 1`] = `
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -24634,29 +26258,87 @@ exports[`BuildQuote View renders correctly when sdkError is present 1`] = `
}
}
>
-
-
- Back
-
-
+
+
+
-
-
- Cancel
-
-
+
+
+
@@ -25220,12 +26960,12 @@ exports[`BuildQuote View renders correctly when sdkError is present 2`] = `
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -25278,29 +27018,87 @@ exports[`BuildQuote View renders correctly when sdkError is present 2`] = `
}
}
>
-
-
- Back
-
-
+
+
+
-
-
- Cancel
-
-
+
+
+
diff --git a/app/components/UI/Ramp/Aggregator/Views/Checkout.test.tsx b/app/components/UI/Ramp/Aggregator/Views/Checkout.test.tsx
deleted file mode 100644
index 2ba79ff2f23..00000000000
--- a/app/components/UI/Ramp/Aggregator/Views/Checkout.test.tsx
+++ /dev/null
@@ -1,246 +0,0 @@
-import { Provider } from '@consensys/on-ramp-sdk';
-import { renderScreen } from '../../../../../util/test/renderWithProvider';
-import Checkout from './Checkout';
-import { RampSDK } from '../sdk';
-import Routes from '../../../../../constants/navigation/Routes';
-import { backgroundState } from '../../../../../util/test/initial-root-state';
-import { MOCK_ACCOUNTS_CONTROLLER_STATE } from '../../../../../util/test/accountsControllerTestUtils';
-import { createCustomOrderIdData } from '../orderProcessor/customOrderId';
-import { Network } from '@consensys/on-ramp-sdk/dist/API';
-import Logger from '../../../../../util/Logger';
-
-const mockDispatch = jest.fn();
-jest.mock('react-redux', () => ({
- ...jest.requireActual('react-redux'),
- useDispatch: () => mockDispatch,
-}));
-
-const mockTrackEvent = jest.fn();
-jest.mock('../../hooks/useAnalytics', () => () => mockTrackEvent);
-
-const mockHandleSuccessfulOrder = jest.fn();
-jest.mock(
- '../hooks/useHandleSuccessfulOrder',
- () => () => mockHandleSuccessfulOrder,
-);
-
-const mockSetOptions = jest.fn();
-const mockNavigation = {
- setOptions: mockSetOptions,
- dangerouslyGetParent: () => ({ pop: jest.fn() }),
-};
-jest.mock('@react-navigation/native', () => ({
- ...jest.requireActual('@react-navigation/native'),
- useNavigation: () => mockNavigation,
-}));
-
-jest.mock('../orderProcessor/customOrderId');
-
-const mockUseRampSDKInitialValues: Partial = {
- selectedAddress: '0x123',
- selectedAsset: undefined,
- sdkError: undefined,
- callbackBaseUrl: 'https://callback.test',
- isBuy: true,
-};
-
-let mockUseRampSDKValues: Partial = {
- ...mockUseRampSDKInitialValues,
-};
-
-jest.mock('../sdk', () => ({
- ...jest.requireActual('../sdk'),
- useRampSDK: () => mockUseRampSDKValues,
- SDK: {
- orders: jest.fn().mockResolvedValue({
- getOrderFromCallback: jest.fn(),
- }),
- },
-}));
-
-const mockUseParams = jest.fn(() => ({
- url: 'https://test.url',
- customOrderId: 'test-order-id',
- provider: { id: 'test-provider', name: 'Test Provider' } as Provider,
-}));
-
-jest.mock('../../../../../util/navigation/navUtils', () => ({
- ...jest.requireActual('../../../../../util/navigation/navUtils'),
- useParams: () => mockUseParams(),
-}));
-
-function render() {
- return renderScreen(
- Checkout,
- {
- name: Routes.RAMP.CHECKOUT,
- },
- {
- state: {
- engine: {
- backgroundState: {
- ...backgroundState,
- AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE,
- },
- },
- },
- },
- );
-}
-
-describe('Checkout', () => {
- beforeEach(() => {
- jest.clearAllMocks();
- mockUseRampSDKValues = {
- ...mockUseRampSDKInitialValues,
- };
- });
-
- it('uses selectedAsset network chainId when available', () => {
- mockUseRampSDKValues.selectedAsset = {
- id: '1',
- idv2: '2',
- legacyId: 'legacy-1',
- network: {
- active: true,
- chainId: '137',
- chainName: 'Polygon',
- shortName: 'Polygon',
- },
- symbol: 'USDC',
- logo: 'logo',
- decimals: 6,
- address: '0x123',
- name: 'USD Coin',
- limits: ['1', '1000'],
- sellEnabled: true,
- assetId: 'asset-1',
- } as const;
-
- render();
-
- expect(createCustomOrderIdData).toHaveBeenCalledWith(
- 'test-order-id',
- '137',
- '0x123',
- 'BUY',
- );
- });
-
- it('uses selectedAsset network chainId when chainId is 1', () => {
- mockUseRampSDKValues.selectedAsset = {
- id: '1',
- idv2: '2',
- legacyId: 'legacy-1',
- network: {
- active: true,
- chainId: '1',
- chainName: 'Test',
- shortName: 'Test',
- },
- symbol: 'USDC',
- logo: 'logo',
- decimals: 6,
- address: '0x123',
- name: 'USD Coin',
- limits: ['1', '1000'],
- sellEnabled: true,
- assetId: 'asset-1',
- } as const;
-
- render();
-
- expect(createCustomOrderIdData).toHaveBeenCalledWith(
- 'test-order-id',
- '1',
- '0x123',
- 'BUY',
- );
- });
-
- it('handles undefined selectedAsset gracefully', () => {
- mockUseRampSDKValues.selectedAsset = undefined;
-
- render();
-
- expect(createCustomOrderIdData).not.toHaveBeenCalled();
- });
-
- it('returns early from handleCancelPress when chainId is not available', () => {
- mockUseRampSDKValues.selectedAsset = {
- id: '1',
- idv2: '2',
- legacyId: 'legacy-1',
- network: {
- active: true,
- chainId: undefined as unknown as string, // No chainId
- chainName: 'Test',
- shortName: 'Test',
- },
- symbol: 'USDC',
- logo: 'logo',
- decimals: 6,
- address: '0x123',
- name: 'USD Coin',
- limits: ['1', '1000'],
- sellEnabled: true,
- assetId: 'asset-1',
- } as const;
-
- render();
-
- expect(mockTrackEvent).not.toHaveBeenCalled();
- });
-
- it('returns early from handleCancelPress when selectedAsset network is undefined', () => {
- mockUseRampSDKValues.selectedAsset = {
- id: '1',
- idv2: '2',
- legacyId: 'legacy-1',
- network: undefined as unknown as Network, // No network
- symbol: 'USDC',
- logo: 'logo',
- decimals: 6,
- address: '0x123',
- name: 'USD Coin',
- limits: ['1', '1000'],
- sellEnabled: true,
- assetId: 'asset-1',
- } as const;
-
- render();
-
- expect(mockTrackEvent).not.toHaveBeenCalled();
- });
-
- it('handles navigation state change when selectedAddress is undefined', () => {
- mockUseRampSDKValues.selectedAddress = undefined;
-
- render();
-
- expect(mockHandleSuccessfulOrder).not.toHaveBeenCalled();
- });
-
- it('logs error when selectedAddress is undefined during navigation state change', async () => {
- mockUseRampSDKValues.selectedAddress = undefined;
-
- const mockLoggerError = jest.spyOn(Logger, 'error');
-
- const { getByTestId } = render();
-
- const webView = getByTestId('checkout-webview');
- if (webView?.props?.onNavigationStateChange) {
- await webView.props.onNavigationStateChange({
- url: 'https://callback.test?success=true',
- loading: false,
- title: '',
- canGoBack: false,
- canGoForward: false,
- });
- }
-
- expect(mockLoggerError).toHaveBeenCalledWith(
- new Error('No address available for selected asset'),
- );
- });
-});
diff --git a/app/components/UI/Ramp/Aggregator/Views/Checkout.tsx b/app/components/UI/Ramp/Aggregator/Views/Checkout.tsx
deleted file mode 100644
index 8e1002283a5..00000000000
--- a/app/components/UI/Ramp/Aggregator/Views/Checkout.tsx
+++ /dev/null
@@ -1,224 +0,0 @@
-import React, { useCallback, useEffect, useState } from 'react';
-import { View } from 'react-native';
-import { useDispatch } from 'react-redux';
-import { parseUrl } from 'query-string';
-import { WebView, WebViewNavigation } from '@metamask/react-native-webview';
-import { useNavigation } from '@react-navigation/native';
-import { Provider } from '@consensys/on-ramp-sdk';
-import { OrderOrderTypeEnum } from '@consensys/on-ramp-sdk/dist/API';
-import { baseStyles } from '../../../../../styles/common';
-import { useTheme } from '../../../../../util/theme';
-import { getFiatOnRampAggNavbar } from '../../../Navbar';
-import { useRampSDK, SDK } from '../sdk';
-import {
- addFiatCustomIdData,
- removeFiatCustomIdData,
-} from '../../../../../reducers/fiatOrders';
-import { CustomIdData } from '../../../../../reducers/fiatOrders/types';
-import {
- createNavigationDetails,
- useParams,
-} from '../../../../../util/navigation/navUtils';
-import { aggregatorOrderToFiatOrder } from '../orderProcessor/aggregator';
-import { createCustomOrderIdData } from '../orderProcessor/customOrderId';
-import ScreenLayout from '../components/ScreenLayout';
-import ErrorView from '../components/ErrorView';
-import ErrorViewWithReporting from '../components/ErrorViewWithReporting';
-import useAnalytics from '../../hooks/useAnalytics';
-import { strings } from '../../../../../../locales/i18n';
-import Routes from '../../../../../constants/navigation/Routes';
-import useHandleSuccessfulOrder from '../hooks/useHandleSuccessfulOrder';
-import Logger from '../../../../../util/Logger';
-
-interface CheckoutParams {
- url: string;
- customOrderId?: string;
- provider: Provider;
-}
-
-export const createCheckoutNavDetails = createNavigationDetails(
- Routes.RAMP.CHECKOUT,
-);
-
-const CheckoutWebView = () => {
- const { selectedAsset, selectedAddress, sdkError, callbackBaseUrl, isBuy } =
- useRampSDK();
- const dispatch = useDispatch();
- const trackEvent = useAnalytics();
- const [error, setError] = useState('');
- const [customIdData, setCustomIdData] = useState();
- const [isRedirectionHandled, setIsRedirectionHandled] = useState(false);
- const [key, setKey] = useState(0);
- const navigation = useNavigation();
- const params = useParams();
- const { colors } = useTheme();
- const handleSuccessfulOrder = useHandleSuccessfulOrder();
-
- const { url: uri, customOrderId, provider } = params;
-
- const handleCancelPress = useCallback(() => {
- const chainId = selectedAsset?.network?.chainId;
- if (!chainId) return;
-
- if (isBuy) {
- trackEvent('ONRAMP_CANCELED', {
- location: 'Provider Webview',
- chain_id_destination: chainId,
- provider_onramp: provider.name,
- });
- } else {
- trackEvent('OFFRAMP_CANCELED', {
- location: 'Provider Webview',
- chain_id_source: chainId,
- provider_offramp: provider.name,
- });
- }
- }, [isBuy, provider.name, selectedAsset?.network?.chainId, trackEvent]);
-
- useEffect(() => {
- navigation.setOptions(
- getFiatOnRampAggNavbar(
- navigation,
- { title: provider.name, showNetwork: false },
- colors,
- handleCancelPress,
- ),
- );
- }, [navigation, colors, handleCancelPress, provider.name]);
-
- useEffect(() => {
- if (
- !customOrderId ||
- !selectedAsset?.network?.chainId ||
- !selectedAddress
- ) {
- return;
- }
- const customOrderIdData = createCustomOrderIdData(
- customOrderId,
- selectedAsset.network.chainId,
- selectedAddress,
- isBuy ? OrderOrderTypeEnum.Buy : OrderOrderTypeEnum.Sell,
- );
- setCustomIdData(customOrderIdData);
- dispatch(addFiatCustomIdData(customOrderIdData));
- }, [customOrderId, dispatch, isBuy, selectedAsset, selectedAddress]);
-
- const handleNavigationStateChange = async (navState: WebViewNavigation) => {
- if (
- !isRedirectionHandled &&
- navState.url.startsWith(callbackBaseUrl) &&
- navState.loading === false
- ) {
- setIsRedirectionHandled(true);
- try {
- const parsedUrl = parseUrl(navState.url);
- if (Object.keys(parsedUrl.query).length === 0) {
- // There was no query params in the URL to parse
- // Most likely the user clicked the X in Wyre widget
- // @ts-expect-error navigation prop mismatch
- navigation.dangerouslyGetParent()?.pop();
- return;
- }
- if (!selectedAddress) {
- Logger.error(new Error('No address available for selected asset'));
- return;
- }
- const orders = await SDK.orders();
- const getOrderFromCallbackMethod = isBuy
- ? 'getOrderFromCallback'
- : 'getSellOrderFromCallback';
- const order = await orders[getOrderFromCallbackMethod](
- provider.id,
- navState?.url,
- selectedAddress,
- );
-
- if (!order) {
- throw new Error(
- `Order could not be retrieved. Callback was ${navState?.url}`,
- );
- }
-
- if (customIdData) {
- dispatch(removeFiatCustomIdData(customIdData));
- }
-
- const transformedOrder = {
- ...aggregatorOrderToFiatOrder(order),
- account: selectedAddress,
- };
-
- handleSuccessfulOrder(transformedOrder);
- } catch (navStateError) {
- setError((navStateError as Error)?.message);
- }
- }
- };
-
- if (sdkError) {
- return (
-
-
-
-
-
- );
- }
-
- if (error) {
- return (
-
-
- {
- setKey((prevKey) => prevKey + 1);
- setError('');
- setIsRedirectionHandled(false);
- }}
- location={'Provider Webview'}
- />
-
-
- );
- }
-
- if (uri) {
- return (
-
- {
- const { nativeEvent } = syntheticEvent;
- if (
- nativeEvent.url === uri ||
- nativeEvent.url.startsWith(callbackBaseUrl)
- ) {
- const webviewHttpError = strings(
- 'fiat_on_ramp_aggregator.webview_received_error',
- { code: nativeEvent.statusCode },
- );
- setError(webviewHttpError);
- }
- }}
- allowsInlineMediaPlayback
- enableApplePay
- paymentRequestEnabled
- mediaPlaybackRequiresUserAction={false}
- onNavigationStateChange={handleNavigationStateChange}
- userAgent={provider?.features?.buy?.userAgent ?? undefined}
- testID="checkout-webview"
- />
-
- );
- }
-
- return null;
-};
-
-export default CheckoutWebView;
diff --git a/app/components/UI/Ramp/Aggregator/Views/Checkout/Checkout.styles.ts b/app/components/UI/Ramp/Aggregator/Views/Checkout/Checkout.styles.ts
new file mode 100644
index 00000000000..df5fea79316
--- /dev/null
+++ b/app/components/UI/Ramp/Aggregator/Views/Checkout/Checkout.styles.ts
@@ -0,0 +1,14 @@
+import { StyleSheet } from 'react-native';
+import { Theme } from '../../../../../../util/theme/models';
+
+const styleSheet = (params: { theme: Theme }) =>
+ StyleSheet.create({
+ headerWithoutPadding: {
+ paddingVertical: 0,
+ },
+ webview: {
+ backgroundColor: params.theme.colors.background.default,
+ },
+ });
+
+export default styleSheet;
diff --git a/app/components/UI/Ramp/Aggregator/Views/Checkout/Checkout.test.tsx b/app/components/UI/Ramp/Aggregator/Views/Checkout/Checkout.test.tsx
new file mode 100644
index 00000000000..5ab4a0f2025
--- /dev/null
+++ b/app/components/UI/Ramp/Aggregator/Views/Checkout/Checkout.test.tsx
@@ -0,0 +1,400 @@
+import { CryptoCurrency, Order, Provider } from '@consensys/on-ramp-sdk';
+import { fireEvent, act } from '@testing-library/react-native';
+import {
+ DeepPartial,
+ renderScreen,
+} from '../../../../../../util/test/renderWithProvider';
+import { RampSDK, SDK } from '../../sdk';
+import Checkout from '.';
+import Routes from '../../../../../../constants/navigation/Routes';
+import { aggregatorOrderToFiatOrder } from '../../orderProcessor/aggregator';
+
+const mockDispatch = jest.fn();
+jest.mock('react-redux', () => ({
+ ...jest.requireActual('react-redux'),
+ useDispatch: () => mockDispatch,
+}));
+
+const mockTrackEvent = jest.fn();
+jest.mock('../../../hooks/useAnalytics', () => () => mockTrackEvent);
+
+const mockHandleSuccessfulOrder = jest.fn();
+jest.mock(
+ '../../hooks/useHandleSuccessfulOrder',
+ () => () => mockHandleSuccessfulOrder,
+);
+
+const mockSetOptions = jest.fn();
+const mockPop = jest.fn();
+const mockNavigation = {
+ goBack: jest.fn(),
+ setOptions: mockSetOptions,
+ dangerouslyGetParent: () => ({ pop: mockPop }),
+};
+jest.mock('@react-navigation/native', () => ({
+ ...jest.requireActual('@react-navigation/native'),
+ useNavigation: () => mockNavigation,
+}));
+
+const mockedSelectedAsset = {
+ id: '1',
+ idv2: '2',
+ legacyId: 'legacy-1',
+ network: {
+ active: true,
+ chainId: '137',
+ chainName: 'Polygon',
+ shortName: 'Polygon',
+ },
+ symbol: 'USDC',
+ logo: 'logo',
+ decimals: 6,
+ address: '0x123',
+ name: 'USD Coin',
+ limits: ['1', '1000'],
+ sellEnabled: true,
+ assetId: 'asset-1',
+} as CryptoCurrency;
+
+const mockUseRampSDKInitialValues: DeepPartial = {
+ selectedAddress: '0x123',
+ selectedAsset: mockedSelectedAsset,
+ sdkError: undefined,
+ callbackBaseUrl: 'https://callback.test',
+ isBuy: true,
+};
+
+let mockUseRampSDKValues: DeepPartial = {
+ ...mockUseRampSDKInitialValues,
+};
+
+jest.mock('../../sdk', () => ({
+ ...jest.requireActual('../../sdk'),
+ useRampSDK: () => mockUseRampSDKValues,
+ SDK: {
+ orders: jest.fn().mockResolvedValue({
+ getOrderFromCallback: jest.fn(),
+ }),
+ },
+}));
+
+const mockUseParams = jest.fn(() => ({
+ url: 'https://test.url',
+ customOrderId: 'test-order-id',
+ provider: { id: 'test-provider', name: 'Test Provider' } as Provider,
+}));
+
+jest.mock('../../../../../../util/navigation/navUtils', () => ({
+ ...jest.requireActual('../../../../../../util/navigation/navUtils'),
+ useParams: () => mockUseParams(),
+}));
+
+function render() {
+ return renderScreen(Checkout, {
+ name: Routes.RAMP.CHECKOUT,
+ });
+}
+
+describe('Checkout', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseRampSDKValues = {
+ ...mockUseRampSDKInitialValues,
+ };
+ });
+
+ it('displays WebView when url is present and no errors', () => {
+ const { toJSON } = render();
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('displays sell WebView when url is present and no errors', () => {
+ mockUseRampSDKValues.isBuy = false;
+ const { toJSON } = render();
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('displays sdkError when present', () => {
+ mockUseRampSDKValues.sdkError = new Error('SDK Error');
+ const { toJSON } = render();
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('displays and tracks error if no url or errors', () => {
+ mockUseRampSDKValues.selectedAsset = undefined;
+ mockUseParams.mockReturnValueOnce({
+ url: '',
+ customOrderId: 'test-order-id',
+ provider: { id: 'test-provider', name: 'Test Provider' } as Provider,
+ });
+ const { toJSON } = render();
+ expect(toJSON()).toMatchSnapshot();
+ expect(mockTrackEvent).toHaveBeenCalledWith(
+ 'ONRAMP_ERROR',
+ expect.any(Object),
+ );
+ });
+
+ it('closes and tracks buy cancel event on bottom sheet close', () => {
+ const { getByTestId } = render();
+ const closeButton = getByTestId('checkout-close-button');
+ act(() => {
+ closeButton.props.onPress();
+ });
+ expect(mockTrackEvent).toHaveBeenCalledWith('ONRAMP_CANCELED', {
+ chain_id_destination: '137',
+ location: 'Provider Webview',
+ provider_onramp: 'Test Provider',
+ });
+ expect(mockTrackEvent).toHaveBeenCalledTimes(1);
+ expect(mockNavigation.goBack).toHaveBeenCalled();
+ });
+
+ it('closes and tracks sell cancel event on bottom sheet close', () => {
+ mockUseRampSDKValues.isBuy = false;
+ const { getByTestId } = render();
+ const closeButton = getByTestId('checkout-close-button');
+ act(() => {
+ closeButton.props.onPress();
+ });
+ expect(mockTrackEvent).toHaveBeenCalledWith('OFFRAMP_CANCELED', {
+ chain_id_source: '137',
+ location: 'Provider Webview',
+ provider_offramp: 'Test Provider',
+ });
+ expect(mockTrackEvent).toHaveBeenCalledTimes(1);
+ expect(mockNavigation.goBack).toHaveBeenCalled();
+ });
+
+ it('closes and tracks buy cancel event on error bottom sheet close', () => {
+ mockUseRampSDKValues.selectedAsset = undefined;
+ mockUseParams.mockReturnValueOnce({
+ url: '',
+ customOrderId: 'test-order-id',
+ provider: {
+ id: 'test-provider',
+ name: 'Test Provider',
+ } as Provider,
+ });
+ const { getByTestId } = render();
+ const closeButton = getByTestId('checkout-close-button');
+ act(() => {
+ closeButton.props.onPress();
+ });
+ expect(mockTrackEvent).toHaveBeenCalledWith('ONRAMP_CANCELED', {
+ chain_id_destination: '',
+ location: 'Provider Webview',
+ provider_onramp: 'Test Provider',
+ });
+ expect(mockNavigation.goBack).toHaveBeenCalled();
+ });
+
+ it('sets and displays error on http error in WebView', async () => {
+ const { getByTestId, toJSON, getByText } = render();
+ const webView = getByTestId('checkout-webview');
+ await act(async () => {
+ await webView.props.onHttpError({
+ nativeEvent: {
+ statusCode: 500,
+ description: 'Server Error',
+ url: 'https://test.url',
+ },
+ });
+ });
+ expect(toJSON()).toMatchSnapshot();
+
+ const tryAgainButton = getByText('Try again');
+ expect(tryAgainButton).toBeDefined();
+ act(() => {
+ fireEvent.press(tryAgainButton);
+ });
+ });
+
+ it('sets and displays error on http error in WebView for callback url', async () => {
+ const { getByTestId, toJSON } = render();
+ const webView = getByTestId('checkout-webview');
+ await act(async () => {
+ await webView.props.onHttpError({
+ nativeEvent: {
+ statusCode: 500,
+ description: 'Server Error',
+ url: 'https://callback.test',
+ },
+ });
+ });
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('ignores irrelevant error on http error in WebView for callback url', async () => {
+ const { getByTestId, toJSON } = render();
+ const webView = getByTestId('checkout-webview');
+ await act(async () => {
+ await webView.props.onHttpError({
+ nativeEvent: {
+ statusCode: 500,
+ description: 'Server Error',
+ url: 'https://irrelevant.url',
+ },
+ });
+ });
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('ignores irrelevant url navigation state changes', async () => {
+ const { getByTestId } = render();
+ const webView = getByTestId('checkout-webview');
+ await act(async () => {
+ await webView.props.onNavigationStateChange({
+ url: 'https://irrelevant.url',
+ loading: false,
+ });
+ });
+ expect(mockHandleSuccessfulOrder).not.toHaveBeenCalled();
+ });
+
+ it('ignores url navigation state changes when is loading', async () => {
+ const { getByTestId } = render();
+ const webView = getByTestId('checkout-webview');
+ await act(async () => {
+ await webView.props.onNavigationStateChange({
+ url: 'https://callback.test?success=true',
+ loading: true,
+ });
+ });
+ expect(mockHandleSuccessfulOrder).not.toHaveBeenCalled();
+ });
+
+ it('closes webview when url has no query params', async () => {
+ const { getByTestId } = render();
+ const webView = getByTestId('checkout-webview');
+ await act(async () => {
+ await webView.props.onNavigationStateChange({
+ url: 'https://callback.test',
+ loading: false,
+ });
+ });
+
+ expect(mockHandleSuccessfulOrder).not.toHaveBeenCalled();
+ expect(mockPop).toHaveBeenCalled();
+ });
+
+ it('sets error when handling url navigation state change and selectedAddress is undefined', async () => {
+ mockUseRampSDKValues.selectedAddress = undefined;
+ const { getByTestId, toJSON, getByText } = render();
+ const webView = getByTestId('checkout-webview');
+ await act(async () => {
+ await webView.props.onNavigationStateChange({
+ url: 'https://callback.test?success=true',
+ loading: false,
+ });
+ });
+
+ expect(toJSON()).toMatchSnapshot();
+ expect(
+ getByText('No wallet address was provided to continue'),
+ ).toBeDefined();
+ });
+
+ it('handles successful buy order on url navigation state change', async () => {
+ const mockOrder = {
+ id: 'order-1',
+ status: 'COMPLETED',
+ };
+ const mockGetOrderFromCallback = jest.fn().mockResolvedValue(mockOrder);
+ (SDK.orders as jest.Mock).mockResolvedValueOnce({
+ getOrderFromCallback: mockGetOrderFromCallback,
+ });
+ const { getByTestId } = render();
+ const webView = getByTestId('checkout-webview');
+ await act(async () => {
+ await webView.props.onNavigationStateChange({
+ url: 'https://callback.test?success=true',
+ loading: false,
+ });
+ });
+
+ expect(mockGetOrderFromCallback).toHaveBeenCalledWith(
+ 'test-provider',
+ 'https://callback.test?success=true',
+ '0x123',
+ );
+ expect(mockHandleSuccessfulOrder).toHaveBeenCalledWith({
+ ...aggregatorOrderToFiatOrder(mockOrder as Order),
+ account: '0x123',
+ });
+ });
+
+ it('handles successful sell order on url navigation state change', async () => {
+ mockUseRampSDKValues.isBuy = false;
+ mockUseParams.mockReturnValueOnce({
+ url: 'https://test.url',
+ customOrderId: '',
+ provider: { id: 'test-provider', name: 'Test Provider' } as Provider,
+ });
+
+ const mockSellOrder = {
+ id: 'order-1',
+ status: 'COMPLETED',
+ };
+ const mockGetSellOrderFromCallback = jest
+ .fn()
+ .mockResolvedValue(mockSellOrder);
+ (SDK.orders as jest.Mock).mockResolvedValueOnce({
+ getSellOrderFromCallback: mockGetSellOrderFromCallback,
+ });
+ const { getByTestId } = render();
+ const webView = getByTestId('checkout-webview');
+ await act(async () => {
+ await webView.props.onNavigationStateChange({
+ url: 'https://callback.test?success=true',
+ loading: false,
+ });
+ });
+
+ expect(mockGetSellOrderFromCallback).toHaveBeenCalledWith(
+ 'test-provider',
+ 'https://callback.test?success=true',
+ '0x123',
+ );
+ expect(mockHandleSuccessfulOrder).toHaveBeenCalledWith({
+ ...aggregatorOrderToFiatOrder(mockSellOrder as Order),
+ account: '0x123',
+ });
+ });
+
+ it('handles get order error gracefully', async () => {
+ const mockGetOrderFromCallback = jest
+ .fn()
+ .mockRejectedValue(new Error('Get order error'));
+ (SDK.orders as jest.Mock).mockResolvedValueOnce({
+ getOrderFromCallback: mockGetOrderFromCallback,
+ });
+ const { getByTestId, toJSON } = render();
+ const webView = getByTestId('checkout-webview');
+ await act(async () => {
+ await webView.props.onNavigationStateChange({
+ url: 'https://callback.test?success=true',
+ loading: false,
+ });
+ });
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('handles undefined order gracefully', async () => {
+ const mockGetOrderFromCallback = jest.fn().mockResolvedValue(undefined);
+ (SDK.orders as jest.Mock).mockResolvedValueOnce({
+ getOrderFromCallback: mockGetOrderFromCallback,
+ });
+ const { getByTestId, toJSON } = render();
+ const webView = getByTestId('checkout-webview');
+ await act(async () => {
+ await webView.props.onNavigationStateChange({
+ url: 'https://callback.test?success=true',
+ loading: false,
+ });
+ });
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+});
diff --git a/app/components/UI/Ramp/Aggregator/Views/Checkout/Checkout.tsx b/app/components/UI/Ramp/Aggregator/Views/Checkout/Checkout.tsx
new file mode 100644
index 00000000000..239b5f58d22
--- /dev/null
+++ b/app/components/UI/Ramp/Aggregator/Views/Checkout/Checkout.tsx
@@ -0,0 +1,335 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { useDispatch } from 'react-redux';
+import { parseUrl } from 'query-string';
+import { WebView, WebViewNavigation } from '@metamask/react-native-webview';
+import { useNavigation } from '@react-navigation/native';
+import { Provider } from '@consensys/on-ramp-sdk';
+import { OrderOrderTypeEnum } from '@consensys/on-ramp-sdk/dist/API';
+import { useTheme } from '../../../../../../util/theme';
+import { getDepositNavbarOptions } from '../../../../Navbar';
+import { useRampSDK, SDK } from '../../sdk';
+import {
+ addFiatCustomIdData,
+ removeFiatCustomIdData,
+} from '../../../../../../reducers/fiatOrders';
+import { CustomIdData } from '../../../../../../reducers/fiatOrders/types';
+import {
+ createNavigationDetails,
+ useParams,
+} from '../../../../../../util/navigation/navUtils';
+import { aggregatorOrderToFiatOrder } from '../../orderProcessor/aggregator';
+import { createCustomOrderIdData } from '../../orderProcessor/customOrderId';
+import ScreenLayout from '../../components/ScreenLayout';
+import ErrorView from '../../components/ErrorView';
+import ErrorViewWithReporting from '../../components/ErrorViewWithReporting';
+import useAnalytics from '../../../hooks/useAnalytics';
+import { strings } from '../../../../../../../locales/i18n';
+import Routes from '../../../../../../constants/navigation/Routes';
+import useHandleSuccessfulOrder from '../../hooks/useHandleSuccessfulOrder';
+import Logger from '../../../../../../util/Logger';
+import BottomSheet, {
+ BottomSheetRef,
+} from '../../../../../../component-library/components/BottomSheets/BottomSheet';
+import BottomSheetHeader from '../../../../../../component-library/components/BottomSheets/BottomSheetHeader';
+import ButtonIcon, {
+ ButtonIconSizes,
+} from '../../../../../../component-library/components/Buttons/ButtonIcon';
+import {
+ IconColor,
+ IconName,
+} from '../../../../../../component-library/components/Icons/Icon';
+import { useStyles } from '../../../../../../component-library/hooks';
+import styleSheet from './Checkout.styles';
+
+interface CheckoutParams {
+ url: string;
+ customOrderId?: string;
+ provider: Provider;
+}
+
+export const createCheckoutNavDetails = createNavigationDetails(
+ Routes.RAMP.CHECKOUT,
+);
+
+const CheckoutWebView = () => {
+ const { selectedAsset, selectedAddress, sdkError, callbackBaseUrl, isBuy } =
+ useRampSDK();
+ const sheetRef = useRef(null);
+ const dispatch = useDispatch();
+ const trackEvent = useAnalytics();
+ const [error, setError] = useState('');
+ const [customIdData, setCustomIdData] = useState();
+ const [isRedirectionHandled, setIsRedirectionHandled] = useState(false);
+ const [key, setKey] = useState(0);
+ const navigation = useNavigation();
+ const params = useParams();
+ const theme = useTheme();
+ const handleSuccessfulOrder = useHandleSuccessfulOrder();
+
+ const { styles } = useStyles(styleSheet, {});
+
+ const { url: uri, customOrderId, provider } = params;
+
+ const handleCancelPress = useCallback(() => {
+ const chainId = selectedAsset?.network?.chainId || '';
+ if (isBuy) {
+ trackEvent('ONRAMP_CANCELED', {
+ location: 'Provider Webview',
+ chain_id_destination: chainId,
+ provider_onramp: provider.name,
+ });
+ } else {
+ trackEvent('OFFRAMP_CANCELED', {
+ location: 'Provider Webview',
+ chain_id_source: chainId,
+ provider_offramp: provider.name,
+ });
+ }
+ }, [isBuy, provider.name, selectedAsset?.network?.chainId, trackEvent]);
+
+ const handleClosePress = useCallback(() => {
+ handleCancelPress();
+ sheetRef.current?.onCloseBottomSheet();
+ }, [handleCancelPress]);
+
+ useEffect(() => {
+ navigation.setOptions(
+ getDepositNavbarOptions(
+ navigation,
+ { title: provider.name },
+ theme,
+ handleCancelPress,
+ ),
+ );
+ }, [navigation, theme, handleCancelPress, provider.name]);
+
+ useEffect(() => {
+ if (
+ !customOrderId ||
+ !selectedAsset?.network?.chainId ||
+ !selectedAddress
+ ) {
+ return;
+ }
+ const customOrderIdData = createCustomOrderIdData(
+ customOrderId,
+ selectedAsset.network.chainId,
+ selectedAddress,
+ isBuy ? OrderOrderTypeEnum.Buy : OrderOrderTypeEnum.Sell,
+ );
+ setCustomIdData(customOrderIdData);
+ dispatch(addFiatCustomIdData(customOrderIdData));
+ }, [customOrderId, dispatch, isBuy, selectedAsset, selectedAddress]);
+
+ const handleNavigationStateChange = async (navState: WebViewNavigation) => {
+ if (
+ !isRedirectionHandled &&
+ navState.url.startsWith(callbackBaseUrl) &&
+ navState.loading === false
+ ) {
+ setIsRedirectionHandled(true);
+ try {
+ const parsedUrl = parseUrl(navState.url);
+ if (Object.keys(parsedUrl.query).length === 0) {
+ // There was no query params in the URL to parse
+ // Most likely the user clicked the X in Wyre widget
+ // @ts-expect-error navigation prop mismatch
+ navigation.dangerouslyGetParent()?.pop();
+ return;
+ }
+ if (!selectedAddress) {
+ Logger.error(new Error('No address available for selected asset'));
+ setError(
+ strings(
+ 'fiat_on_ramp_aggregator.webview_error_no_address_provided',
+ ),
+ );
+ return;
+ }
+ const orders = await SDK.orders();
+ const getOrderFromCallbackMethod = isBuy
+ ? 'getOrderFromCallback'
+ : 'getSellOrderFromCallback';
+ const order = await orders[getOrderFromCallbackMethod](
+ provider.id,
+ navState?.url,
+ selectedAddress,
+ );
+
+ if (!order) {
+ const noOrderError = new Error(
+ `Order could not be retrieved. Callback was ${navState?.url}`,
+ );
+ Logger.error(noOrderError);
+ throw noOrderError;
+ }
+
+ if (customIdData) {
+ dispatch(removeFiatCustomIdData(customIdData));
+ }
+
+ const transformedOrder = {
+ ...aggregatorOrderToFiatOrder(order),
+ account: selectedAddress,
+ };
+
+ handleSuccessfulOrder(transformedOrder);
+ } catch (navStateError) {
+ setError((navStateError as Error)?.message);
+ }
+ }
+ };
+
+ if (sdkError) {
+ return (
+
+
+ }
+ style={styles.headerWithoutPadding}
+ />
+
+
+
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+ }
+ style={styles.headerWithoutPadding}
+ />
+
+
+
+ {
+ setKey((prevKey) => prevKey + 1);
+ setError('');
+ setIsRedirectionHandled(false);
+ }}
+ location={'Provider Webview'}
+ />
+
+
+
+ );
+ }
+
+ if (uri) {
+ return (
+
+
+ }
+ style={styles.headerWithoutPadding}
+ />
+ {
+ const { nativeEvent } = syntheticEvent;
+ if (
+ nativeEvent.url === uri ||
+ nativeEvent.url.startsWith(callbackBaseUrl)
+ ) {
+ const webviewHttpError = strings(
+ 'fiat_on_ramp_aggregator.webview_received_error',
+ { code: nativeEvent.statusCode },
+ );
+ setError(webviewHttpError);
+ }
+ }}
+ allowsInlineMediaPlayback
+ enableApplePay
+ paymentRequestEnabled
+ mediaPlaybackRequiresUserAction={false}
+ onNavigationStateChange={handleNavigationStateChange}
+ userAgent={provider?.features?.buy?.userAgent ?? undefined}
+ testID="checkout-webview"
+ />
+
+ );
+ }
+
+ return (
+
+
+ }
+ style={styles.headerWithoutPadding}
+ />
+
+
+
+
+
+
+ );
+};
+
+export default CheckoutWebView;
diff --git a/app/components/UI/Ramp/Aggregator/Views/Checkout/__snapshots__/Checkout.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/Checkout/__snapshots__/Checkout.test.tsx.snap
new file mode 100644
index 00000000000..356b0cfd1aa
--- /dev/null
+++ b/app/components/UI/Ramp/Aggregator/Views/Checkout/__snapshots__/Checkout.test.tsx.snap
@@ -0,0 +1,6866 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Checkout displays WebView when url is present and no errors 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+ Checkout
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Checkout displays and tracks error if no url or errors 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+ Checkout
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Error
+
+
+
+
+ No URL was provided to continue
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Checkout displays sdkError when present 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+ Checkout
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Oops, something went wrong
+
+
+
+
+ SDK Error
+
+
+
+
+
+ Return to Home Screen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Checkout displays sell WebView when url is present and no errors 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+ Checkout
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Checkout handles get order error gracefully 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+ Checkout
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Error
+
+
+
+
+ Get order error
+
+
+
+
+
+ Try again
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Checkout handles undefined order gracefully 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+ Checkout
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Error
+
+
+
+
+ Order could not be retrieved. Callback was https://callback.test?success=true
+
+
+
+
+
+ Try again
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Checkout ignores irrelevant error on http error in WebView for callback url 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+ Checkout
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Checkout sets and displays error on http error in WebView 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+ Checkout
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Error
+
+
+
+
+ WebView received error status code: 500
+
+
+
+
+
+ Try again
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Checkout sets and displays error on http error in WebView for callback url 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+ Checkout
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Error
+
+
+
+
+ WebView received error status code: 500
+
+
+
+
+
+ Try again
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Checkout sets error when handling url navigation state change and selectedAddress is undefined 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+ Checkout
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Error
+
+
+
+
+ No wallet address was provided to continue
+
+
+
+
+
+ Try again
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/app/components/UI/Ramp/Aggregator/Views/Checkout/index.ts b/app/components/UI/Ramp/Aggregator/Views/Checkout/index.ts
new file mode 100644
index 00000000000..959886b6735
--- /dev/null
+++ b/app/components/UI/Ramp/Aggregator/Views/Checkout/index.ts
@@ -0,0 +1 @@
+export { default } from './Checkout';
diff --git a/app/components/UI/Ramp/Aggregator/Views/GetStarted/GetStarted.test.tsx b/app/components/UI/Ramp/Aggregator/Views/GetStarted/GetStarted.test.tsx
index a1944830271..03d04f8ffbe 100644
--- a/app/components/UI/Ramp/Aggregator/Views/GetStarted/GetStarted.test.tsx
+++ b/app/components/UI/Ramp/Aggregator/Views/GetStarted/GetStarted.test.tsx
@@ -156,9 +156,9 @@ describe('GetStarted', () => {
it('navigates and tracks event on cancel button press', async () => {
render(GetStarted);
- fireEvent.press(screen.getByRole('button', { name: 'Cancel' }));
+ fireEvent.press(screen.getByTestId('deposit-close-navbar-button'));
expect(mockPop).toHaveBeenCalled();
- expect(mockTrackEvent).toBeCalledWith('ONRAMP_CANCELED', {
+ expect(mockTrackEvent).toHaveBeenCalledWith('ONRAMP_CANCELED', {
chain_id_destination: '1',
location: 'Get Started Screen',
});
@@ -171,8 +171,8 @@ describe('GetStarted', () => {
rampType: RampType.SELL,
};
render(GetStarted);
- fireEvent.press(screen.getByRole('button', { name: 'Cancel' }));
- expect(mockTrackEvent).toBeCalledWith('OFFRAMP_CANCELED', {
+ fireEvent.press(screen.getByTestId('deposit-close-navbar-button'));
+ expect(mockTrackEvent).toHaveBeenCalledWith('OFFRAMP_CANCELED', {
chain_id_source: '1',
location: 'Get Started Screen',
});
@@ -190,7 +190,7 @@ describe('GetStarted', () => {
};
render(GetStarted);
expect(mockReset).toBeCalledTimes(1);
- expect(mockReset).toBeCalledWith({
+ expect(mockReset).toHaveBeenCalledWith({
index: 0,
routes: [
{
diff --git a/app/components/UI/Ramp/Aggregator/Views/GetStarted/GetStarted.tsx b/app/components/UI/Ramp/Aggregator/Views/GetStarted/GetStarted.tsx
index 6d1a7671733..4fc6aa23bc5 100644
--- a/app/components/UI/Ramp/Aggregator/Views/GetStarted/GetStarted.tsx
+++ b/app/components/UI/Ramp/Aggregator/Views/GetStarted/GetStarted.tsx
@@ -4,7 +4,7 @@ import { useNavigation } from '@react-navigation/native';
import Text from '../../../../../Base/Text';
import StyledButton from '../../../../StyledButton';
import ScreenLayout from '../../components/ScreenLayout';
-import { getFiatOnRampAggNavbar } from '../../../../Navbar';
+import { getDepositNavbarOptions } from '../../../../Navbar';
import { strings } from '../../../../../../../locales/i18n';
import { useTheme } from '../../../../../../util/theme';
import { useRampSDK } from '../../sdk';
@@ -34,8 +34,7 @@ const GetStarted: React.FC = () => {
const [isNetworkRampSupported] = useRampNetwork();
const trackEvent = useAnalytics();
const params = useParams();
-
- const { colors } = useTheme();
+ const theme = useTheme();
const handleCancelPress = useCallback(() => {
const chainId = selectedAsset?.network?.chainId;
@@ -62,17 +61,17 @@ const GetStarted: React.FC = () => {
useEffect(() => {
navigation.setOptions(
- getFiatOnRampAggNavbar(
+ getDepositNavbarOptions(
navigation,
{
title: strings('fiat_on_ramp_aggregator.onboarding.what_to_expect'),
showBack: false,
},
- colors,
+ theme,
handleCancelPress,
),
);
- }, [navigation, colors, handleCancelPress]);
+ }, [navigation, theme, handleCancelPress]);
const handleOnPress = useCallback(() => {
trackEvent(
diff --git a/app/components/UI/Ramp/Aggregator/Views/GetStarted/__snapshots__/GetStarted.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/GetStarted/__snapshots__/GetStarted.test.tsx.snap
index 96d826c3e5d..9b59d20cabc 100644
--- a/app/components/UI/Ramp/Aggregator/Views/GetStarted/__snapshots__/GetStarted.test.tsx.snap
+++ b/app/components/UI/Ramp/Aggregator/Views/GetStarted/__snapshots__/GetStarted.test.tsx.snap
@@ -58,12 +58,12 @@ exports[`GetStarted renders correctly 1`] = `
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -106,24 +106,7 @@ exports[`GetStarted renders correctly 1`] = `
pointerEvents="box-none"
style={
{
- "alignItems": "flex-start",
- "bottom": 0,
- "justifyContent": "center",
- "left": 0,
- "opacity": 1,
- "position": "absolute",
- "top": 0,
- }
- }
- >
-
-
-
-
-
- Cancel
-
-
+
+
+
@@ -695,12 +736,12 @@ exports[`GetStarted renders correctly 2`] = `
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -743,24 +784,7 @@ exports[`GetStarted renders correctly 2`] = `
pointerEvents="box-none"
style={
{
- "alignItems": "flex-start",
- "bottom": 0,
- "justifyContent": "center",
- "left": 0,
- "opacity": 1,
- "position": "absolute",
- "top": 0,
- }
- }
- >
-
-
-
-
-
- Cancel
-
-
+
+
+
@@ -1332,12 +1414,12 @@ exports[`GetStarted renders correctly when getStarted is true 1`] = `
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -1380,24 +1462,7 @@ exports[`GetStarted renders correctly when getStarted is true 1`] = `
pointerEvents="box-none"
style={
{
- "alignItems": "flex-start",
- "bottom": 0,
- "justifyContent": "center",
- "left": 0,
- "opacity": 1,
- "position": "absolute",
- "top": 0,
- }
- }
- >
-
-
-
-
-
- Cancel
-
-
+
+
+
@@ -1750,12 +1873,12 @@ exports[`GetStarted renders correctly when sdkError is present 1`] = `
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -1798,24 +1921,7 @@ exports[`GetStarted renders correctly when sdkError is present 1`] = `
pointerEvents="box-none"
style={
{
- "alignItems": "flex-start",
- "bottom": 0,
- "justifyContent": "center",
- "left": 0,
- "opacity": 1,
- "position": "absolute",
- "top": 0,
- }
- }
- >
-
-
-
-
-
- Cancel
-
-
+
+
+
diff --git a/app/components/UI/Ramp/Aggregator/Views/OrderDetails/OrderDetails.tsx b/app/components/UI/Ramp/Aggregator/Views/OrderDetails/OrderDetails.tsx
index 514d967fd32..813842ced1a 100644
--- a/app/components/UI/Ramp/Aggregator/Views/OrderDetails/OrderDetails.tsx
+++ b/app/components/UI/Ramp/Aggregator/Views/OrderDetails/OrderDetails.tsx
@@ -17,7 +17,7 @@ import {
updateFiatOrder,
} from '../../../../../../reducers/fiatOrders';
import { strings } from '../../../../../../../locales/i18n';
-import { getFiatOnRampAggNavbar } from '../../../../Navbar';
+import { getDepositNavbarOptions } from '../../../../Navbar';
import Routes from '../../../../../../constants/navigation/Routes';
import { processFiatOrder } from '../../../index';
import {
@@ -55,7 +55,8 @@ const OrderDetails = () => {
order?.state === FIAT_ORDER_STATES.CREATED,
);
const [error, setError] = useState(null);
- const { colors } = useTheme();
+ const theme = useTheme();
+ const { colors } = theme;
const navigation = useNavigation();
const dispatch = useDispatch();
const dispatchThunk = useThunkDispatch();
@@ -66,17 +67,16 @@ const OrderDetails = () => {
useEffect(() => {
navigation.setOptions(
- getFiatOnRampAggNavbar(
+ getDepositNavbarOptions(
navigation,
{
title: strings('fiat_on_ramp_aggregator.order_details.details_main'),
- showCancel: false,
- showNetwork: false,
+ showClose: false,
},
- colors,
+ theme,
),
);
- }, [colors, navigation]);
+ }, [theme, navigation]);
const navigateToSendTransaction = useCallback(() => {
if (order?.id) {
diff --git a/app/components/UI/Ramp/Aggregator/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap
index 8f5f3b380b6..59e706e47db 100644
--- a/app/components/UI/Ramp/Aggregator/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap
+++ b/app/components/UI/Ramp/Aggregator/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap
@@ -58,12 +58,12 @@ exports[`OrderDetails renders a cancelled order 1`] = `
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -116,29 +116,87 @@ exports[`OrderDetails renders a cancelled order 1`] = `
}
}
>
-
-
- Back
-
-
+
+
+
-
-
-
@@ -1510,12 +1551,12 @@ exports[`OrderDetails renders a completed order 1`] = `
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -1568,29 +1609,87 @@ exports[`OrderDetails renders a completed order 1`] = `
}
}
>
-
-
- Back
-
-
+
+
+
-
-
-
@@ -2980,12 +3062,12 @@ exports[`OrderDetails renders a created order 1`] = `
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -3038,29 +3120,87 @@ exports[`OrderDetails renders a created order 1`] = `
}
}
>
-
-
- Back
-
-
+
+
+
-
-
-
@@ -4413,12 +4536,12 @@ exports[`OrderDetails renders a failed order 1`] = `
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -4471,29 +4594,87 @@ exports[`OrderDetails renders a failed order 1`] = `
}
}
>
-
-
- Back
-
-
+
+
+
-
-
-
@@ -5865,12 +6029,12 @@ exports[`OrderDetails renders a pending order 1`] = `
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -5923,29 +6087,87 @@ exports[`OrderDetails renders a pending order 1`] = `
}
}
>
-
-
- Back
-
-
+
+
+
-
-
-
@@ -7298,12 +7503,12 @@ exports[`OrderDetails renders an empty screen layout if there is no order 1`] =
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -7356,29 +7561,87 @@ exports[`OrderDetails renders an empty screen layout if there is no order 1`] =
}
}
>
-
-
- Back
-
-
+
+
+
-
-
-
@@ -7716,12 +7962,12 @@ exports[`OrderDetails renders an error screen if a CREATED order cannot be polle
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -7774,29 +8020,87 @@ exports[`OrderDetails renders an error screen if a CREATED order cannot be polle
}
}
>
-
-
- Back
-
-
+
+
+
-
-
-
@@ -8338,12 +8625,12 @@ exports[`OrderDetails renders non-transacted orders 1`] = `
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -8396,29 +8683,87 @@ exports[`OrderDetails renders non-transacted orders 1`] = `
}
}
>
-
-
- Back
-
-
+
+
+
-
-
-
@@ -9851,12 +10179,12 @@ exports[`OrderDetails renders the support links if the provider has them 1`] = `
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -9909,29 +10237,87 @@ exports[`OrderDetails renders the support links if the provider has them 1`] = `
}
}
>
-
-
- Back
-
-
+
+
+
-
-
-
@@ -11367,12 +11736,12 @@ exports[`OrderDetails renders transacted orders that do not have timeDescription
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -11425,29 +11794,87 @@ exports[`OrderDetails renders transacted orders that do not have timeDescription
}
}
>
-
-
- Back
-
-
+
+
+
-
-
-
@@ -12800,12 +13210,12 @@ exports[`OrderDetails renders transacted orders that have timeDescriptionPending
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -12858,29 +13268,87 @@ exports[`OrderDetails renders transacted orders that have timeDescriptionPending
}
}
>
-
-
- Back
-
-
+
+
+
-
-
-
diff --git a/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.test.tsx b/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.test.tsx
index c18db76c438..d9fad040ee6 100644
--- a/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.test.tsx
+++ b/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.test.tsx
@@ -232,9 +232,9 @@ describe('Quotes', () => {
it('navigates and tracks event on cancel button press', async () => {
render(Quotes);
- fireEvent.press(screen.getByRole('button', { name: 'Cancel' }));
+ fireEvent.press(screen.getByTestId('deposit-close-navbar-button'));
expect(mockPop).toHaveBeenCalled();
- expect(mockTrackEvent).toBeCalledWith('ONRAMP_CANCELED', {
+ expect(mockTrackEvent).toHaveBeenCalledWith('ONRAMP_CANCELED', {
chain_id_destination: '1',
location: 'Quotes Screen',
results_count:
@@ -251,8 +251,8 @@ describe('Quotes', () => {
mockUseRampSDKValues.isSell = true;
mockUseRampSDKValues.isBuy = false;
render(Quotes);
- fireEvent.press(screen.getByRole('button', { name: 'Cancel' }));
- expect(mockTrackEvent).toBeCalledWith('OFFRAMP_CANCELED', {
+ fireEvent.press(screen.getByTestId('deposit-close-navbar-button'));
+ expect(mockTrackEvent).toHaveBeenCalledWith('OFFRAMP_CANCELED', {
chain_id_source: '1',
location: 'Quotes Screen',
results_count:
@@ -604,12 +604,12 @@ describe('Quotes', () => {
};
await simulateCustomActionCtaPress();
- expect(createWidgetMock).toBeCalledWith(
+ expect(createWidgetMock).toHaveBeenCalledWith(
mockUseRampSDKValues.callbackBaseUrl,
);
expect(mockNavigate).toBeCalledTimes(1);
- expect(mockNavigate).toBeCalledWith(Routes.RAMP.CHECKOUT, {
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.RAMP.CHECKOUT, {
provider: mockCustomAction.buy.provider,
customOrderId: 'test-order-id',
url: 'https://test-url.on-ramp.metamask',
@@ -630,7 +630,7 @@ describe('Quotes', () => {
await simulateCustomActionCtaPress();
- expect(mockRenderInAppBrowser).toBeCalledWith(
+ expect(mockRenderInAppBrowser).toHaveBeenCalledWith(
mockedBuyAction,
mockCustomAction.buy.provider,
50,
@@ -646,7 +646,7 @@ describe('Quotes', () => {
ProviderBuyFeatureBrowserEnum.AppBrowser,
);
expect(mockNavigate).toBeCalledTimes(1);
- expect(mockNavigate).toBeCalledWith(Routes.RAMP.CHECKOUT, {
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.RAMP.CHECKOUT, {
provider: mockedRecommendedQuote.provider,
customOrderId: 'test-order-id',
url: 'https://test-url.on-ramp.metamask',
@@ -720,7 +720,7 @@ describe('Quotes', () => {
ProviderBuyFeatureBrowserEnum.InAppOsBrowser,
);
- expect(mockRenderInAppBrowser).toBeCalledWith(
+ expect(mockRenderInAppBrowser).toHaveBeenCalledWith(
mockedBuyAction,
mockedRecommendedQuote.provider,
mockedRecommendedQuote.amountIn,
diff --git a/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.tsx b/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.tsx
index 4f3bccd9386..3ae77471264 100644
--- a/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.tsx
+++ b/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.tsx
@@ -29,7 +29,7 @@ import Row from '../../components/Row';
import Quote from '../../components/Quote';
import CustomAction from '../../components/CustomAction';
import InfoAlert from '../../components/InfoAlert';
-import { getFiatOnRampAggNavbar } from '../../../../Navbar';
+import { getDepositNavbarOptions } from '../../../../Navbar';
import {
ButtonSize,
ButtonVariants,
@@ -55,7 +55,7 @@ import { strings } from '../../../../../../../locales/i18n';
import LoadingAnimation from '../../components/LoadingAnimation';
import useInterval from '../../../../../hooks/useInterval';
import useInAppBrowser from '../../hooks/useInAppBrowser';
-import { createCheckoutNavDetails } from '../Checkout';
+import { createCheckoutNavDetails } from '../Checkout/Checkout';
import { PROVIDER_LINKS, ScreenLocation } from '../../types';
import Logger from '../../../../../../util/Logger';
import { isBuyQuote } from '../../utils';
@@ -579,14 +579,14 @@ function Quotes() {
useEffect(() => {
navigation.setOptions(
- getFiatOnRampAggNavbar(
+ getDepositNavbarOptions(
navigation,
{ title: strings('fiat_on_ramp_aggregator.select_a_quote') },
- theme.colors,
+ theme,
handleCancelPress,
),
);
- }, [navigation, theme.colors, handleCancelPress]);
+ }, [navigation, theme, handleCancelPress]);
useEffect(() => {
if (isFetchingQuotes) return;
diff --git a/app/components/UI/Ramp/Aggregator/Views/Quotes/__snapshots__/Quotes.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/Quotes/__snapshots__/Quotes.test.tsx.snap
index 3231e1726a2..ca10793fccf 100644
--- a/app/components/UI/Ramp/Aggregator/Views/Quotes/__snapshots__/Quotes.test.tsx.snap
+++ b/app/components/UI/Ramp/Aggregator/Views/Quotes/__snapshots__/Quotes.test.tsx.snap
@@ -582,12 +582,12 @@ exports[`Quotes custom action renders correctly after animation with the recomme
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -640,29 +640,87 @@ exports[`Quotes custom action renders correctly after animation with the recomme
}
}
>
-
-
- Back
-
-
+
+
+
-
-
- Cancel
-
-
+
+
+
@@ -1588,12 +1704,12 @@ exports[`Quotes renders animation on first fetching 1`] = `
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -1646,29 +1762,87 @@ exports[`Quotes renders animation on first fetching 1`] = `
}
}
>
-
-
- Back
-
-
+
+
+
-
-
- Cancel
-
-
+
+
+
@@ -2664,12 +2896,12 @@ exports[`Quotes renders correctly after animation with expanded quotes 2`] = `
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -2722,29 +2954,87 @@ exports[`Quotes renders correctly after animation with expanded quotes 2`] = `
}
}
>
-
-
- Back
-
-
+
+
+
-
-
- Cancel
-
-
+
+
+
@@ -4454,12 +4802,12 @@ exports[`Quotes renders correctly after animation with the recommended quote 1`]
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -4512,29 +4860,87 @@ exports[`Quotes renders correctly after animation with the recommended quote 1`]
}
}
>
-
-
- Back
-
-
+
+
+
-
-
- Cancel
-
-
+
+
+
@@ -5610,12 +6074,12 @@ exports[`Quotes renders correctly after animation without quotes 1`] = `
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -5668,29 +6132,87 @@ exports[`Quotes renders correctly after animation without quotes 1`] = `
}
}
>
-
-
- Back
-
-
+
+
+
-
-
- Cancel
-
-
+
+
+
@@ -6335,12 +6915,12 @@ exports[`Quotes renders correctly when fetching quotes errors 1`] = `
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -6393,29 +6973,87 @@ exports[`Quotes renders correctly when fetching quotes errors 1`] = `
}
}
>
-
-
- Back
-
-
+
+
+
-
-
- Cancel
-
-
+
+
+
@@ -7060,12 +7756,12 @@ exports[`Quotes renders correctly with sdkError 1`] = `
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -7118,29 +7814,87 @@ exports[`Quotes renders correctly with sdkError 1`] = `
}
}
>
-
-
- Back
-
-
+
+
+
-
-
- Cancel
-
-
+
+
+
@@ -7785,12 +8597,12 @@ exports[`Quotes renders quotes expired screen 1`] = `
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -7843,29 +8655,87 @@ exports[`Quotes renders quotes expired screen 1`] = `
}
}
>
-
-
- Back
-
-
+
+
+
-
-
- Cancel
-
-
+
+
+
diff --git a/app/components/UI/Ramp/Aggregator/Views/SendTransaction/SendTransaction.styles.ts b/app/components/UI/Ramp/Aggregator/Views/SendTransaction/SendTransaction.styles.ts
index 5b8583660c0..6727e76c893 100644
--- a/app/components/UI/Ramp/Aggregator/Views/SendTransaction/SendTransaction.styles.ts
+++ b/app/components/UI/Ramp/Aggregator/Views/SendTransaction/SendTransaction.styles.ts
@@ -6,6 +6,10 @@ const styleSheet = (_params: { theme: Theme }) =>
centered: {
textAlign: 'center',
},
+ textRow: {
+ flexDirection: 'row',
+ gap: 4,
+ },
content: {
flex: 1,
justifyContent: 'center',
diff --git a/app/components/UI/Ramp/Aggregator/Views/SendTransaction/SendTransaction.tsx b/app/components/UI/Ramp/Aggregator/Views/SendTransaction/SendTransaction.tsx
index fb74e1aa383..f23f37f9699 100644
--- a/app/components/UI/Ramp/Aggregator/Views/SendTransaction/SendTransaction.tsx
+++ b/app/components/UI/Ramp/Aggregator/Views/SendTransaction/SendTransaction.tsx
@@ -44,7 +44,7 @@ import {
getProviderName,
setFiatSellTxHash,
} from '../../../../../../reducers/fiatOrders';
-import { getFiatOnRampAggNavbar } from '../../../../Navbar';
+import { getDepositNavbarOptions } from '../../../../Navbar';
import { useParams } from '../../../../../../util/navigation/navUtils';
import {
addHexPrefix,
@@ -81,10 +81,8 @@ function SendTransaction() {
const [isConfirming, setIsConfirming] = useState(false);
- const {
- styles,
- theme: { colors, themeAppearance },
- } = useStyles(styleSheet, {});
+ const { styles, theme } = useStyles(styleSheet, {});
+ const { colors, themeAppearance } = theme;
const orderData = order?.data as SellOrder;
@@ -105,19 +103,18 @@ function SendTransaction() {
useEffect(() => {
navigation.setOptions(
- getFiatOnRampAggNavbar(
+ getDepositNavbarOptions(
navigation,
{
title: strings(
'fiat_on_ramp_aggregator.send_transaction.sell_crypto',
),
- showCancel: false,
- showNetwork: false,
+ showClose: false,
},
- colors,
+ theme,
),
);
- }, [colors, navigation]);
+ }, [theme, navigation]);
const transactionAnalyticsPayload = useMemo(
() => ({
@@ -255,11 +252,9 @@ function SendTransaction() {
-
+
- {strings(
- 'fiat_on_ramp_aggregator.send_transaction.send',
- )}{' '}
+ {strings('fiat_on_ramp_aggregator.send_transaction.send')}
{fromTokenMinimalUnitString(
@@ -268,18 +263,18 @@ function SendTransaction() {
orderData.cryptoCurrency.decimals,
).toString(),
orderData.cryptoCurrency.decimals,
- )}{' '}
+ )}
{' '}
+ />
{order.cryptocurrency}
-
+
diff --git a/app/components/UI/Ramp/Aggregator/Views/SendTransaction/__snapshots__/SendTransaction.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/SendTransaction/__snapshots__/SendTransaction.test.tsx.snap
index 5eb1c70101c..622768e4403 100644
--- a/app/components/UI/Ramp/Aggregator/Views/SendTransaction/__snapshots__/SendTransaction.test.tsx.snap
+++ b/app/components/UI/Ramp/Aggregator/Views/SendTransaction/__snapshots__/SendTransaction.test.tsx.snap
@@ -58,12 +58,12 @@ exports[`SendTransaction View renders correctly 1`] = `
"borderBottomColor": "rgb(216, 216, 216)",
"elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
+ "shadowColor": "rgb(216, 216, 216)",
"shadowOffset": {
"height": 0.5,
"width": 0,
},
- "shadowOpacity": 0.85,
+ "shadowOpacity": 0,
"shadowRadius": 0,
}
}
@@ -116,29 +116,87 @@ exports[`SendTransaction View renders correctly 1`] = `
}
}
>
-
-
- Back
-
-
+
+
+
-
-
-
@@ -449,16 +490,11 @@ exports[`SendTransaction View renders correctly 1`] = `
]
}
>
-
@@ -476,7 +512,6 @@ exports[`SendTransaction View renders correctly 1`] = `
}
>
Send
-
0.012361263
-
-
ETH
-
+
-
-
- Back
-
-
+
+
+
-
-
-
@@ -1233,16 +1307,11 @@ exports[`SendTransaction View renders correctly for custom action payment method
]
}
>
-
@@ -1260,7 +1329,6 @@ exports[`SendTransaction View renders correctly for custom action payment method
}
>
Send
-
0.0123456
-
-
USDC
-
+
-
-
- Back
-
-
+
+
+
-
-
-
@@ -1935,16 +2042,11 @@ exports[`SendTransaction View renders correctly for token 1`] = `
]
}
>
-
@@ -1962,7 +2064,6 @@ exports[`SendTransaction View renders correctly for token 1`] = `
}
>
Send
-
0.0123456
-
-
USDC
-
+
(
detachPreviousScreen: false,
}}
/>
-
+
);
diff --git a/app/components/UI/Ramp/Deposit/Views/AdditionalVerification/__snapshots__/AdditionalVerification.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/AdditionalVerification/__snapshots__/AdditionalVerification.test.tsx.snap
index 9959d379ea1..fe54a783c20 100644
--- a/app/components/UI/Ramp/Deposit/Views/AdditionalVerification/__snapshots__/AdditionalVerification.test.tsx.snap
+++ b/app/components/UI/Ramp/Deposit/Views/AdditionalVerification/__snapshots__/AdditionalVerification.test.tsx.snap
@@ -313,7 +313,7 @@ exports[`AdditionalVerification Component render matches snapshot 1`] = `
},
]
}
- testID="button-icon"
+ testID="deposit-close-navbar-button"
>
StyleSheet.create({
@@ -184,6 +191,11 @@ export default class AppInformation extends PureComponent {
const colors = this.context.colors || mockTheme.colors;
const styles = createStyles(colors);
+ const otaUpdateMessage =
+ __DEV__ || isEmbeddedLaunch
+ ? 'This app is running from built-in code or in development mode'
+ : 'This app is running an update';
+
return (
- {this.state.appInfo}
+
+ {getFullVersion(this.state.appInfo)}
+
{isQa ? (
{`Branch: ${process.env['GIT_BRANCH']}`}
) : null}
- {this.state.showEnvironmentInfo ? (
+ {this.state.showEnvironmentInfo && (
<>
{`Environment: ${process.env.METAMASK_ENVIRONMENT}`}
-
{`Remote Feature Flag Env: ${getFeatureFlagAppEnvironment()}`}
{`Remote Feature Flag Distribution: ${getFeatureFlagAppDistribution()}`}
+
+ {`OTA Updates enabled: ${String(isOTAUpdatesEnabled)}`}
+
+ {isOTAUpdatesEnabled && (
+ <>
+
+ {`OTA Update Channel: ${channel}`}
+
+
+ {`OTA Update runtime version: ${runtimeVersion}`}
+
+
+ {`OTA Update status: ${otaUpdateMessage}`}
+
+ >
+ )}
>
- ) : null}
+ )}
{strings('app_information.links')}
diff --git a/app/constants/deeplinks.ts b/app/constants/deeplinks.ts
index 1fc6c939323..d1cc02049b8 100644
--- a/app/constants/deeplinks.ts
+++ b/app/constants/deeplinks.ts
@@ -38,6 +38,7 @@ export enum ACTIONS {
PERPS_MARKETS = 'perps-markets',
PERPS_ASSET = 'perps-asset',
REWARDS = 'rewards',
+ PREDICT = 'predict',
ONBOARDING = 'onboarding',
}
@@ -63,6 +64,7 @@ export const PREFIXES = {
[ACTIONS.PERPS_MARKETS]: '',
[ACTIONS.PERPS_ASSET]: '',
[ACTIONS.REWARDS]: '',
+ [ACTIONS.PREDICT]: '',
[ACTIONS.ONBOARDING]: '',
[ACTIONS.ENABLE_CARD_BUTTON]: '',
METAMASK: 'metamask://',
diff --git a/app/constants/ota.ts b/app/constants/ota.ts
new file mode 100644
index 00000000000..2a43a7d6a30
--- /dev/null
+++ b/app/constants/ota.ts
@@ -0,0 +1,21 @@
+/**
+ * OTA (Over-The-Air) Update Version Tracking
+ *
+ * Re-exports from the root ota.config.js file (single source of truth).
+ * To update versions, edit ota.config.js at the project root.
+ */
+
+import otaConfig from '../../ota.config.js';
+
+export const OTA_VERSION = otaConfig.OTA_VERSION;
+export const RUNTIME_VERSION = otaConfig.RUNTIME_VERSION;
+
+/**
+ * Gets the full version string including OTA version
+ * @param appVersion - The app version from package.json/device info
+ * @returns Full version string (e.g., "7.58.0 OTA Version: v3")
+ */
+export const getFullVersion = (appVersion: string): string =>
+ process.env.METAMASK_ENVIRONMENT !== 'production' && OTA_VERSION !== 'v0'
+ ? `${appVersion} OTA: ${OTA_VERSION}`
+ : `${appVersion}`;
diff --git a/app/core/DeeplinkManager/DeeplinkManager.ts b/app/core/DeeplinkManager/DeeplinkManager.ts
index 1663638462c..ed3dfc6bb4f 100644
--- a/app/core/DeeplinkManager/DeeplinkManager.ts
+++ b/app/core/DeeplinkManager/DeeplinkManager.ts
@@ -24,6 +24,7 @@ import { handleDeeplink } from './Handlers/handleDeeplink';
import SharedDeeplinkManager from './SharedDeeplinkManager';
import FCMService from '../../util/notifications/services/FCMService';
import { handleRewardsUrl } from './Handlers/handleRewardsUrl';
+import { handlePredictUrl } from './Handlers/handlePredictUrl';
import handleFastOnboarding from './Handlers/handleFastOnboarding';
import { handleEnableCardButton } from './Handlers/handleEnableCardButton';
@@ -138,6 +139,12 @@ class DeeplinkManager {
});
}
+ _handlePredict(predictPath: string) {
+ handlePredictUrl({
+ predictPath,
+ });
+ }
+
// NOTE: keeping this for backwards compatibility
_handleOpenSwap() {
this.navigation.navigate(Routes.SWAPS);
diff --git a/app/core/DeeplinkManager/Handlers/handlePredictUrl.test.ts b/app/core/DeeplinkManager/Handlers/handlePredictUrl.test.ts
new file mode 100644
index 00000000000..626a6d8cc79
--- /dev/null
+++ b/app/core/DeeplinkManager/Handlers/handlePredictUrl.test.ts
@@ -0,0 +1,303 @@
+import { handlePredictUrl } from './handlePredictUrl';
+import NavigationService from '../../NavigationService';
+import Routes from '../../../constants/navigation/Routes';
+import DevLogger from '../../SDKConnect/utils/DevLogger';
+
+// Mock dependencies
+jest.mock('../../NavigationService');
+jest.mock('../../SDKConnect/utils/DevLogger');
+
+describe('handlePredictUrl', () => {
+ let mockNavigate: jest.Mock;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ // Setup navigation mocks
+ mockNavigate = jest.fn();
+ NavigationService.navigation = {
+ navigate: mockNavigate,
+ } as unknown as typeof NavigationService.navigation;
+
+ // Mock DevLogger
+ (DevLogger.log as jest.Mock) = jest.fn();
+ });
+
+ describe('with market parameter', () => {
+ it('navigates to market details when market parameter is provided', async () => {
+ await handlePredictUrl({ predictPath: '?market=23246' });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MODALS.ROOT, {
+ screen: Routes.PREDICT.MARKET_DETAILS,
+ params: {
+ marketId: '23246',
+ entryPoint: 'deeplink',
+ },
+ });
+ });
+
+ it('navigates to market details when marketId parameter is provided', async () => {
+ await handlePredictUrl({ predictPath: '?marketId=12345' });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MODALS.ROOT, {
+ screen: Routes.PREDICT.MARKET_DETAILS,
+ params: {
+ marketId: '12345',
+ entryPoint: 'deeplink',
+ },
+ });
+ });
+
+ it('prioritizes market parameter over marketId when both are provided', async () => {
+ await handlePredictUrl({
+ predictPath: '?market=23246&marketId=99999',
+ });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MODALS.ROOT, {
+ screen: Routes.PREDICT.MARKET_DETAILS,
+ params: {
+ marketId: '23246',
+ entryPoint: 'deeplink',
+ },
+ });
+ });
+
+ it('extracts market ID from full URL path', async () => {
+ await handlePredictUrl({ predictPath: 'predict?market=abc789' });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MODALS.ROOT, {
+ screen: Routes.PREDICT.MARKET_DETAILS,
+ params: {
+ marketId: 'abc789',
+ entryPoint: 'deeplink',
+ },
+ });
+ });
+
+ it('handles multiple URL parameters', async () => {
+ await handlePredictUrl({
+ predictPath: '?market=xyz123&utm_source=campaign&debug=true',
+ });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MODALS.ROOT, {
+ screen: Routes.PREDICT.MARKET_DETAILS,
+ params: {
+ marketId: 'xyz123',
+ entryPoint: 'deeplink',
+ },
+ });
+ });
+
+ it('handles numeric market IDs', async () => {
+ await handlePredictUrl({ predictPath: '?market=9876543210' });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MODALS.ROOT, {
+ screen: Routes.PREDICT.MARKET_DETAILS,
+ params: {
+ marketId: '9876543210',
+ entryPoint: 'deeplink',
+ },
+ });
+ });
+
+ it('handles alphanumeric market IDs', async () => {
+ await handlePredictUrl({ predictPath: '?market=abc-123-xyz' });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MODALS.ROOT, {
+ screen: Routes.PREDICT.MARKET_DETAILS,
+ params: {
+ marketId: 'abc-123-xyz',
+ entryPoint: 'deeplink',
+ },
+ });
+ });
+ });
+
+ describe('without market parameter', () => {
+ it('navigates to market list when no parameters provided', async () => {
+ await handlePredictUrl({ predictPath: '' });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MARKET_LIST,
+ });
+ });
+
+ it('navigates to market list when URL has no query parameters', async () => {
+ await handlePredictUrl({ predictPath: 'predict' });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MARKET_LIST,
+ });
+ });
+
+ it('navigates to market list when market parameter is empty', async () => {
+ await handlePredictUrl({ predictPath: '?market=' });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MARKET_LIST,
+ });
+ });
+
+ it('navigates to market list when marketId parameter is empty', async () => {
+ await handlePredictUrl({ predictPath: '?marketId=' });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MARKET_LIST,
+ });
+ });
+
+ it('navigates to market list when only other parameters provided', async () => {
+ await handlePredictUrl({ predictPath: '?utm_source=campaign' });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MARKET_LIST,
+ });
+ });
+ });
+
+ describe('error handling', () => {
+ it('falls back to market list when navigation fails', async () => {
+ mockNavigate.mockImplementationOnce(() => {
+ throw new Error('Navigation error');
+ });
+
+ await handlePredictUrl({ predictPath: '?market=23246' });
+
+ expect(mockNavigate).toHaveBeenCalledTimes(2);
+ expect(mockNavigate).toHaveBeenLastCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MARKET_LIST,
+ });
+ });
+
+ it('falls back to market list when market details navigation throws', async () => {
+ mockNavigate.mockImplementationOnce(() => {
+ throw new Error('Market details navigation error');
+ });
+
+ await handlePredictUrl({ predictPath: '?marketId=invalid' });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MARKET_LIST,
+ });
+ });
+
+ it('handles malformed URL parameters gracefully', async () => {
+ await handlePredictUrl({
+ predictPath: 'predict?invalid¶ms&here',
+ });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MARKET_LIST,
+ });
+ });
+
+ it('logs error details when handling fails', async () => {
+ const testError = new Error('Test error');
+ mockNavigate.mockImplementationOnce(() => {
+ throw testError;
+ });
+
+ await handlePredictUrl({ predictPath: '?market=23246' });
+
+ expect(DevLogger.log).toHaveBeenCalledWith(
+ 'Failed to handle predict deeplink:',
+ testError,
+ );
+ });
+ });
+
+ describe('logging', () => {
+ it('logs start of deeplink handling with path', async () => {
+ await handlePredictUrl({ predictPath: '?market=23246' });
+
+ expect(DevLogger.log).toHaveBeenCalledWith(
+ '[handlePredictUrl] Starting predict deeplink handling with path:',
+ '?market=23246',
+ );
+ });
+
+ it('logs parsed navigation parameters', async () => {
+ await handlePredictUrl({ predictPath: '?market=23246' });
+
+ expect(DevLogger.log).toHaveBeenCalledWith(
+ '[handlePredictUrl] Parsed navigation parameters:',
+ { market: '23246' },
+ );
+ });
+
+ it('logs navigation to market details', async () => {
+ await handlePredictUrl({ predictPath: '?market=23246' });
+
+ expect(DevLogger.log).toHaveBeenCalledWith(
+ '[handlePredictUrl] Navigating to market details for market:',
+ '23246',
+ );
+ });
+
+ it('logs navigation to market list when no market provided', async () => {
+ await handlePredictUrl({ predictPath: '' });
+
+ expect(DevLogger.log).toHaveBeenCalledWith(
+ '[handlePredictUrl] No market parameter, showing list',
+ );
+ });
+
+ it('logs fallback when market ID is empty', async () => {
+ await handlePredictUrl({ predictPath: '?market=' });
+
+ expect(DevLogger.log).toHaveBeenCalledWith(
+ '[handlePredictUrl] No market parameter, showing list',
+ );
+ });
+ });
+
+ describe('URL parsing edge cases', () => {
+ it('handles URL with question mark but no parameters', async () => {
+ await handlePredictUrl({ predictPath: 'predict?' });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MARKET_LIST,
+ });
+ });
+
+ it('handles URL with multiple question marks', async () => {
+ await handlePredictUrl({ predictPath: 'predict?market=123?extra=param' });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MODALS.ROOT, {
+ screen: Routes.PREDICT.MARKET_DETAILS,
+ params: {
+ marketId: '123',
+ entryPoint: 'deeplink',
+ },
+ });
+ });
+
+ it('handles URL with special characters in market ID', async () => {
+ await handlePredictUrl({
+ predictPath: '?market=test_market-123',
+ });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MODALS.ROOT, {
+ screen: Routes.PREDICT.MARKET_DETAILS,
+ params: {
+ marketId: 'test_market-123',
+ entryPoint: 'deeplink',
+ },
+ });
+ });
+
+ it('handles URL with encoded parameters', async () => {
+ await handlePredictUrl({
+ predictPath: '?market=test%20market',
+ });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MODALS.ROOT, {
+ screen: Routes.PREDICT.MARKET_DETAILS,
+ params: {
+ marketId: 'test market',
+ entryPoint: 'deeplink',
+ },
+ });
+ });
+ });
+});
diff --git a/app/core/DeeplinkManager/Handlers/handlePredictUrl.ts b/app/core/DeeplinkManager/Handlers/handlePredictUrl.ts
new file mode 100644
index 00000000000..01bafafe3a9
--- /dev/null
+++ b/app/core/DeeplinkManager/Handlers/handlePredictUrl.ts
@@ -0,0 +1,116 @@
+import NavigationService from '../../NavigationService';
+import Routes from '../../../constants/navigation/Routes';
+import DevLogger from '../../SDKConnect/utils/DevLogger';
+
+interface HandlePredictUrlParams {
+ predictPath: string;
+}
+
+/**
+ * Interface for parsed predict navigation parameters
+ */
+interface PredictNavigationParams {
+ market?: string; // Market ID
+}
+
+/**
+ * Parse URL parameters into typed navigation parameters
+ * @param predictPath The predict URL path with query parameters
+ * @returns Typed navigation parameters
+ */
+const parsePredictNavigationParams = (
+ predictPath: string,
+): PredictNavigationParams => {
+ const urlParams = new URLSearchParams(
+ predictPath.includes('?') ? predictPath.split('?')[1] : '',
+ );
+
+ // Support both 'market' and 'marketId' parameter names
+ const marketId = urlParams.get('market') || urlParams.get('marketId');
+
+ return {
+ market: marketId || undefined,
+ };
+};
+
+/**
+ * Handle market-specific navigation
+ * @param marketId The market ID to navigate to
+ */
+const handleMarketNavigation = (marketId: string) => {
+ DevLogger.log(
+ '[handlePredictUrl] Navigating to market details for market:',
+ marketId,
+ );
+
+ if (!marketId) {
+ DevLogger.log(
+ '[handlePredictUrl] No market ID provided, fallback to market list',
+ );
+ NavigationService.navigation.navigate(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MARKET_LIST,
+ });
+ return;
+ }
+
+ // Navigate to market details with the market ID
+ // Note: Market details is under MODALS.ROOT, not the main ROOT
+ NavigationService.navigation.navigate(Routes.PREDICT.MODALS.ROOT, {
+ screen: Routes.PREDICT.MARKET_DETAILS,
+ params: {
+ marketId,
+ entryPoint: 'deeplink',
+ },
+ });
+};
+
+/**
+ * Predict deeplink handler
+ *
+ * @param params Object containing the predict path
+ *
+ * Supported URL formats:
+ * - https://metamask.app.link/predict
+ * - https://metamask.app.link/predict?market=23246
+ * - https://metamask.app.link/predict?marketId=23246
+ * - https://link.metamask.io/predict?market=23246
+ * - https://link.metamask.io/predict?marketId=23246
+ *
+ * Navigation behavior:
+ * - No market param: Navigate to market list
+ * - market=X or marketId=X: Navigate directly to market details for market X
+ */
+export const handlePredictUrl = async ({
+ predictPath,
+}: HandlePredictUrlParams) => {
+ DevLogger.log(
+ '[handlePredictUrl] Starting predict deeplink handling with path:',
+ predictPath,
+ );
+
+ try {
+ // Parse navigation parameters from URL
+ const navParams = parsePredictNavigationParams(predictPath);
+ DevLogger.log(
+ '[handlePredictUrl] Parsed navigation parameters:',
+ navParams,
+ );
+
+ // If market ID is provided, navigate to market details
+ if (navParams.market) {
+ handleMarketNavigation(navParams.market);
+ } else {
+ // Default to market list
+ DevLogger.log('[handlePredictUrl] No market parameter, showing list');
+ NavigationService.navigation.navigate(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MARKET_LIST,
+ });
+ }
+ } catch (error) {
+ DevLogger.log('Failed to handle predict deeplink:', error);
+ // Fallback to market list on error
+ NavigationService.navigation.navigate(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MARKET_LIST,
+ });
+ }
+};
diff --git a/app/core/DeeplinkManager/ParseManager/handleUniversalLink.test.ts b/app/core/DeeplinkManager/ParseManager/handleUniversalLink.test.ts
index 5eba45b5f6e..663a2bc429c 100644
--- a/app/core/DeeplinkManager/ParseManager/handleUniversalLink.test.ts
+++ b/app/core/DeeplinkManager/ParseManager/handleUniversalLink.test.ts
@@ -49,6 +49,7 @@ describe('handleUniversalLinks', () => {
const mockHandleCreateAccount = jest.fn();
const mockHandlePerps = jest.fn();
const mockHandleRewards = jest.fn();
+ const mockHandlePredict = jest.fn();
const mockHandleFastOnboarding = jest.fn();
const mockHandleEnableCardButton = jest.fn();
const mockConnectToChannel = jest.fn();
@@ -77,6 +78,7 @@ describe('handleUniversalLinks', () => {
_handleCreateAccount: mockHandleCreateAccount,
_handlePerps: mockHandlePerps,
_handleRewards: mockHandleRewards,
+ _handlePredict: mockHandlePredict,
_handleFastOnboarding: mockHandleFastOnboarding,
_handleEnableCardButton: mockHandleEnableCardButton,
} as unknown as DeeplinkManager;
@@ -581,6 +583,101 @@ describe('handleUniversalLinks', () => {
});
});
+ describe('ACTIONS.PREDICT', () => {
+ it('calls _handlePredict when action is PREDICT without market parameter', async () => {
+ const predictUrl = `${PROTOCOLS.HTTPS}://${AppConstants.MM_UNIVERSAL_LINK_HOST}/${ACTIONS.PREDICT}`;
+ const predictUrlObj = {
+ ...urlObj,
+ hostname: AppConstants.MM_UNIVERSAL_LINK_HOST,
+ href: predictUrl,
+ pathname: `/${ACTIONS.PREDICT}`,
+ };
+
+ await handleUniversalLink({
+ instance,
+ handled,
+ urlObj: predictUrlObj,
+ browserCallBack: mockBrowserCallBack,
+ url: predictUrl,
+ source: 'test-source',
+ });
+
+ expect(handled).toHaveBeenCalled();
+ expect(mockHandlePredict).toHaveBeenCalledWith('');
+ });
+
+ it('calls _handlePredict when action is PREDICT with market parameter', async () => {
+ const predictUrl = `${PROTOCOLS.HTTPS}://${AppConstants.MM_UNIVERSAL_LINK_HOST}/${ACTIONS.PREDICT}?market=23246`;
+ const predictUrlObj = {
+ ...urlObj,
+ hostname: AppConstants.MM_UNIVERSAL_LINK_HOST,
+ href: predictUrl,
+ pathname: `/${ACTIONS.PREDICT}`,
+ search: '?market=23246',
+ };
+
+ await handleUniversalLink({
+ instance,
+ handled,
+ urlObj: predictUrlObj,
+ browserCallBack: mockBrowserCallBack,
+ url: predictUrl,
+ source: 'test-source',
+ });
+
+ expect(handled).toHaveBeenCalled();
+ expect(mockHandlePredict).toHaveBeenCalledWith('?market=23246');
+ });
+
+ it('calls _handlePredict when action is PREDICT with marketId parameter', async () => {
+ const predictUrl = `${PROTOCOLS.HTTPS}://${AppConstants.MM_UNIVERSAL_LINK_HOST}/${ACTIONS.PREDICT}?marketId=12345`;
+ const predictUrlObj = {
+ ...urlObj,
+ hostname: AppConstants.MM_UNIVERSAL_LINK_HOST,
+ href: predictUrl,
+ pathname: `/${ACTIONS.PREDICT}`,
+ search: '?marketId=12345',
+ };
+
+ await handleUniversalLink({
+ instance,
+ handled,
+ urlObj: predictUrlObj,
+ browserCallBack: mockBrowserCallBack,
+ url: predictUrl,
+ source: 'test-source',
+ });
+
+ expect(handled).toHaveBeenCalled();
+ expect(mockHandlePredict).toHaveBeenCalledWith('?marketId=12345');
+ });
+
+ it('calls _handlePredict with full query string when multiple parameters present', async () => {
+ const predictUrl = `${PROTOCOLS.HTTPS}://${AppConstants.MM_UNIVERSAL_LINK_HOST}/${ACTIONS.PREDICT}?market=23246&utm_source=campaign`;
+ const predictUrlObj = {
+ ...urlObj,
+ hostname: AppConstants.MM_UNIVERSAL_LINK_HOST,
+ href: predictUrl,
+ pathname: `/${ACTIONS.PREDICT}`,
+ search: '?market=23246&utm_source=campaign',
+ };
+
+ await handleUniversalLink({
+ instance,
+ handled,
+ urlObj: predictUrlObj,
+ browserCallBack: mockBrowserCallBack,
+ url: predictUrl,
+ source: 'test-source',
+ });
+
+ expect(handled).toHaveBeenCalled();
+ expect(mockHandlePredict).toHaveBeenCalledWith(
+ '?market=23246&utm_source=campaign',
+ );
+ });
+ });
+
describe('ACTIONS.WC', () => {
const testCases = [
{
diff --git a/app/core/DeeplinkManager/ParseManager/handleUniversalLink.ts b/app/core/DeeplinkManager/ParseManager/handleUniversalLink.ts
index 26837323915..ce44e0c1612 100644
--- a/app/core/DeeplinkManager/ParseManager/handleUniversalLink.ts
+++ b/app/core/DeeplinkManager/ParseManager/handleUniversalLink.ts
@@ -36,6 +36,7 @@ enum SUPPORTED_ACTIONS {
PERPS_MARKETS = ACTIONS.PERPS_MARKETS,
PERPS_ASSET = ACTIONS.PERPS_ASSET,
REWARDS = ACTIONS.REWARDS,
+ PREDICT = ACTIONS.PREDICT,
WC = ACTIONS.WC,
ONBOARDING = ACTIONS.ONBOARDING,
ENABLE_CARD_BUTTON = ACTIONS.ENABLE_CARD_BUTTON,
@@ -257,6 +258,9 @@ async function handleUniversalLink({
} else if (action === SUPPORTED_ACTIONS.REWARDS) {
const rewardsPath = urlObj.href.replace(BASE_URL_ACTION, '');
instance._handleRewards(rewardsPath);
+ } else if (action === SUPPORTED_ACTIONS.PREDICT) {
+ const predictPath = urlObj.href.replace(BASE_URL_ACTION, '');
+ instance._handlePredict(predictPath);
} else if (action === SUPPORTED_ACTIONS.WC) {
const { params } = extractURLParams(urlObj.href);
const wcURL = params?.uri;
diff --git a/appwright/tests/performance/login/asset-balances.spec.js b/appwright/tests/performance/login/asset-balances.spec.js
new file mode 100644
index 00000000000..b94bda82ce0
--- /dev/null
+++ b/appwright/tests/performance/login/asset-balances.spec.js
@@ -0,0 +1,134 @@
+import { test } from '../../../fixtures/performance-test.js';
+
+import TimerHelper from '../../../utils/TimersHelper.js';
+import WalletMainScreen from '../../../../wdio/screen-objects/WalletMainScreen.js';
+import TabBarModal from '../../../../wdio/screen-objects/Modals/TabBarModal.js';
+import LoginScreen from '../../../../wdio/screen-objects/LoginScreen.js';
+import { login } from '../../../utils/Flows.js';
+
+/**
+ * Scenario: Aggregated Balance Loading Time
+ *
+ * This test measures the time it takes for the aggregated balance to load and stabilize
+ * on the wallet home screen. As tokens are progressively loaded, the balance updates:
+ * Example: $0.00 → $2.15 → $30.25 → stable
+ *
+ * Method: Poll the balance text directly from the UI until it stops changing
+ * Measured: Multiple timing metrics:
+ * 1. Total time: navigation to wallet tab → final stable balance
+ * 2. Render time: first UI render → final stable balance
+ * 3. Time to first non-zero balance
+ *
+ * Stability: Balance is considered stable after showing the same value for 3 consecutive checks (100ms apart)
+ */
+test('Asset Balances - Aggregated Balance Loading Time', async ({
+ device,
+ performanceTracker,
+}, testInfo) => {
+ // Set device for screen objects
+ WalletMainScreen.device = device;
+ TabBarModal.device = device;
+ LoginScreen.device = device;
+
+ // Login to the wallet - this triggers balance loading
+ await login(device);
+
+ // Create timer that will track the entire balance loading process
+ const balanceLoadingTimer = new TimerHelper(
+ 'Aggregated balance loading time (navigation to stable value)',
+ );
+
+ // Navigate to wallet tab and START TIMING
+ balanceLoadingTimer.start();
+ const navigationStartTime = Date.now();
+ await TabBarModal.tapWalletButton();
+
+ // Get the balance container element
+ const balanceContainer = await WalletMainScreen.balanceContainer;
+
+ // Helper function to get the current balance text
+ const getBalanceText = async () => {
+ try {
+ const text = await balanceContainer.getText();
+ return text || '$0.00';
+ } catch {
+ return '$0.00';
+ }
+ };
+
+ const balanceHistory = [];
+ let stableCount = 0;
+ let previousBalance = '';
+ const maxWaitTime = 30000; // 30 seconds max
+ const pollInterval = 100; // Check every 100ms for better granularity
+
+ // Wait for balance container to be visible (Playwright auto-waits on getText)
+ // Just mark when we start polling as the first render time
+ const firstRenderTime = Date.now();
+
+ // Poll until balance stabilizes (same value for 3 consecutive checks)
+ while (stableCount < 3) {
+ if (Date.now() - navigationStartTime > maxWaitTime) {
+ throw new Error('Timeout waiting for balance to stabilize');
+ }
+
+ const currentBalance = await getBalanceText();
+ const now = Date.now();
+ const elapsedSinceNavigation = now - navigationStartTime;
+ const elapsedSinceFirstRender = now - firstRenderTime;
+
+ // Track all balance values we see
+ if (currentBalance !== previousBalance) {
+ balanceHistory.push({
+ balance: currentBalance,
+ timestamp: now,
+ elapsedSinceNavigation,
+ elapsedSinceFirstRender,
+ });
+ stableCount = 0; // Reset stability counter
+ } else {
+ stableCount++;
+ }
+
+ previousBalance = currentBalance;
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
+ }
+
+ // STOP TIMING when balance stabilizes
+ balanceLoadingTimer.stop();
+
+ // Calculate different timing metrics
+ const firstUpdate = balanceHistory[0];
+ const lastUpdate = balanceHistory[balanceHistory.length - 1];
+
+ // Time from navigation to final stable value
+ const totalLoadingDuration = lastUpdate.elapsedSinceNavigation;
+
+ // Time from first render to final stable value
+ const renderToStableDuration = lastUpdate.elapsedSinceFirstRender;
+
+ // Find first non-zero balance update
+ const firstNonZeroUpdate = balanceHistory.find(
+ (entry) => entry.balance !== '$0.00' && entry.balance !== '',
+ );
+ const timeToFirstNonZero = firstNonZeroUpdate
+ ? firstNonZeroUpdate.elapsedSinceNavigation
+ : 0;
+
+ // Add timer to performance tracker
+ performanceTracker.addTimer(balanceLoadingTimer);
+
+ // Attach balance history as custom metrics
+ testInfo.annotations.push({
+ type: 'balance-progression',
+ description: JSON.stringify({
+ updates: balanceHistory,
+ timeToFirstNonZero,
+ totalLoadingDuration,
+ renderToStableDuration,
+ }),
+ });
+
+ // Attach performance metrics to test
+ await performanceTracker.attachToTest(testInfo);
+});
diff --git a/index.js b/index.js
index 04fe4d23286..8bc48024541 100644
--- a/index.js
+++ b/index.js
@@ -8,6 +8,9 @@ import 'react-native-gesture-handler';
// why-did-you-render must run as early as possible (after gesture-handler) in dev
import './wdyr';
+// Required for EAS Updates to resolve assets (.riv, .png, etc.) from OTA bundles
+import 'expo-asset';
+
import * as Sentry from '@sentry/react-native'; // eslint-disable-line import/no-namespace
import { setupSentry } from './app/util/sentry/utils';
import { AppRegistry, LogBox } from 'react-native';
diff --git a/ios/Expo.plist b/ios/Expo.plist
new file mode 100644
index 00000000000..bddb3405b67
--- /dev/null
+++ b/ios/Expo.plist
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj
index fed78a99e09..7e5f4e4ca7a 100644
--- a/ios/MetaMask.xcodeproj/project.pbxproj
+++ b/ios/MetaMask.xcodeproj/project.pbxproj
@@ -88,6 +88,9 @@
CF9895782A3B49BE00B4C9B5 /* RCTMinimizer.m in Sources */ = {isa = PBXBuildFile; fileRef = CF9895762A3B49BE00B4C9B5 /* RCTMinimizer.m */; };
CF98DA9C28D9FEB700096782 /* RCTScreenshotDetect.m in Sources */ = {isa = PBXBuildFile; fileRef = CF98DA9B28D9FEB700096782 /* RCTScreenshotDetect.m */; };
CFD8DFC828EDD4C800CC75F6 /* RCTScreenshotDetect.m in Sources */ = {isa = PBXBuildFile; fileRef = CF98DA9B28D9FEB700096782 /* RCTScreenshotDetect.m */; };
+ E4B580722E32F462008165E1 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = E4B580712E32F462008165E1 /* Expo.plist */; };
+ E4B580732E32F462008165E1 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = E4B580712E32F462008165E1 /* Expo.plist */; };
+ E4B580742E32F462008165E1 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = E4B580712E32F462008165E1 /* Expo.plist */; };
E83DB5522BBDF2AA00536063 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = E83DB5392BBDB14700536063 /* PrivacyInfo.xcprivacy */; };
E83DB5532BBDF2AE00536063 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = E83DB5392BBDB14700536063 /* PrivacyInfo.xcprivacy */; };
E83DB5542BBDF2AF00536063 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = E83DB5392BBDB14700536063 /* PrivacyInfo.xcprivacy */; };
@@ -240,6 +243,7 @@
D2632307C64595BE1B8ABEAF /* libPods-MetaMask.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-MetaMask.a"; sourceTree = BUILT_PRODUCTS_DIR; };
D3350113F0764105B1E60002 /* MM Sans Bold.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = undefined; includeInIndex = 0; lastKnownFileType = unknown; name = "MM Sans Bold.otf"; path = "../app/fonts/MM Sans Bold.otf"; sourceTree = ""; };
DCB5FECA5557491AB06DBCBE /* Geist Bold.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = undefined; includeInIndex = 0; lastKnownFileType = unknown; name = "Geist Bold.otf"; path = "../app/fonts/Geist Bold.otf"; sourceTree = ""; };
+ E4B580712E32F462008165E1 /* Expo.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; };
E7EEA32C976A46B991D55FD4 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-MetaMask-QA/ExpoModulesProvider.swift"; sourceTree = ""; };
E83DB5392BBDB14700536063 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = MetaMask/PrivacyInfo.xcprivacy; sourceTree = SOURCE_ROOT; };
E9629905BA1940ADA4189921 /* Feather.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = Feather.ttf; path = "../node_modules/react-native-vector-icons/Fonts/Feather.ttf"; sourceTree = ""; };
@@ -321,6 +325,7 @@
13B07FAE1A68108700A75B9A /* MetaMask */ = {
isa = PBXGroup;
children = (
+ E4B580712E32F462008165E1 /* Expo.plist */,
E83DB5392BBDB14700536063 /* PrivacyInfo.xcprivacy */,
B339FEA72899852C001B89FB /* MetaMask-QA-Info.plist */,
AA9EDF17249955C7005D89EE /* MetaMaskDebug.entitlements */,
@@ -693,6 +698,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ E4B580742E32F462008165E1 /* Expo.plist in Resources */,
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
158B063B211A72F500DF3C74 /* InpageBridgeWeb3.js in Resources */,
15D158ED210BD912006982B5 /* Metamask.ttf in Resources */,
@@ -721,6 +727,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ E4B580722E32F462008165E1 /* Expo.plist in Resources */,
2EF2826A2B0FF86900D7B4B1 /* Images.xcassets in Resources */,
2EF2826B2B0FF86900D7B4B1 /* InpageBridgeWeb3.js in Resources */,
2EF2826C2B0FF86900D7B4B1 /* Metamask.ttf in Resources */,
@@ -738,6 +745,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ E4B580732E32F462008165E1 /* Expo.plist in Resources */,
B339FF10289ABD70001B89FB /* Images.xcassets in Resources */,
B339FF11289ABD70001B89FB /* InpageBridgeWeb3.js in Resources */,
B339FF12289ABD70001B89FB /* Metamask.ttf in Resources */,
diff --git a/ios/Podfile b/ios/Podfile
index 9ee9351c1a2..26590b1aec2 100644
--- a/ios/Podfile
+++ b/ios/Podfile
@@ -101,6 +101,7 @@ def common_target_logic
# An absolute path to your application root.
:app_path => "#{Pod::Config.instance.installation_root}/..",
:privacy_file_aggregation_enabled => podfile_properties['apple.privacyManifestAggregationEnabled'] != 'false',
+ :hermes_enabled => true
)
end
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 618aad0540d..e47705a0baa 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -7,6 +7,8 @@ PODS:
- React-Core
- CocoaAsyncSocket (7.6.5)
- DoubleConversion (1.1.6)
+ - EASClient (0.13.3):
+ - ExpoModulesCore
- EXApplication (6.0.2):
- ExpoModulesCore
- EXConstants (17.0.8):
@@ -272,6 +274,34 @@ PODS:
- ExpoModulesCore
- ExpoWebBrowser (14.0.2):
- ExpoModulesCore
+ - EXStructuredHeaders (4.0.0)
+ - EXUpdates (0.27.4):
+ - DoubleConversion
+ - EASClient
+ - EXManifests
+ - ExpoModulesCore
+ - EXStructuredHeaders
+ - EXUpdatesInterface
+ - glog
+ - hermes-engine
+ - RCT-Folly (= 2024.10.14.00)
+ - RCTRequired
+ - RCTTypeSafety
+ - ReachabilitySwift
+ - React-Core
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-ImageManager
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/bridging
+ - ReactCommon/turbomodule/core
+ - Yoga
- EXUpdatesInterface (1.0.0):
- ExpoModulesCore
- fast_float (6.1.4)
@@ -473,6 +503,7 @@ PODS:
- FBLazyVector (= 0.76.9)
- RCTRequired (= 0.76.9)
- React-Core (= 0.76.9)
+ - ReachabilitySwift (5.2.4)
- React (0.76.9):
- React-Core (= 0.76.9)
- React-Core/DevSupport (= 0.76.9)
@@ -2867,6 +2898,7 @@ DEPENDENCIES:
- boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`)
- BVLinearGradient (from `../node_modules/react-native-linear-gradient`)
- DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`)
+ - EASClient (from `../node_modules/expo-eas-client/ios`)
- EXApplication (from `../node_modules/expo-application/ios`)
- EXConstants (from `../node_modules/expo-constants/ios`)
- EXJSONUtils (from `../node_modules/expo-json-utils/ios`)
@@ -2888,6 +2920,8 @@ DEPENDENCIES:
- ExpoModulesCore (from `../node_modules/expo-modules-core`)
- ExpoSensors (from `../node_modules/expo-sensors/ios`)
- ExpoWebBrowser (from `../node_modules/expo-web-browser/ios`)
+ - EXStructuredHeaders (from `../node_modules/expo-structured-headers/ios`)
+ - EXUpdates (from `../node_modules/expo-updates/ios`)
- EXUpdatesInterface (from `../node_modules/expo-updates-interface/ios`)
- fast_float (from `../node_modules/react-native/third-party-podspecs/fast_float.podspec`)
- FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`)
@@ -3043,6 +3077,7 @@ SPEC REPOS:
- nanopb
- OpenSSL-Universal
- PromisesObjC
+ - ReachabilitySwift
- React-Codegen
- RiveRuntime
- SDWebImage
@@ -3058,6 +3093,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-linear-gradient"
DoubleConversion:
:podspec: "../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec"
+ EASClient:
+ :path: "../node_modules/expo-eas-client/ios"
EXApplication:
:path: "../node_modules/expo-application/ios"
EXConstants:
@@ -3100,6 +3137,10 @@ EXTERNAL SOURCES:
:path: "../node_modules/expo-sensors/ios"
ExpoWebBrowser:
:path: "../node_modules/expo-web-browser/ios"
+ EXStructuredHeaders:
+ :path: "../node_modules/expo-structured-headers/ios"
+ EXUpdates:
+ :path: "../node_modules/expo-updates/ios"
EXUpdatesInterface:
:path: "../node_modules/expo-updates-interface/ios"
fast_float:
@@ -3360,6 +3401,7 @@ SPEC CHECKSUMS:
BVLinearGradient: cb006ba232a1f3e4f341bb62c42d1098c284da70
CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99
DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385
+ EASClient: 88b5fd19d0787186a0c7e6ba76deb2d4f96395ce
EXApplication: 4c72f6017a14a65e338c5e74fca418f35141e819
EXConstants: fcfc75800824ac2d5c592b5bc74130bad17b146b
EXJSONUtils: 01fc7492b66c234e395dcffdd5f53439c5c29c93
@@ -3381,6 +3423,8 @@ SPEC CHECKSUMS:
ExpoModulesCore: c25d77625038b1968ea1afefc719862c0d8dd993
ExpoSensors: 02a52ddab1e3a8a1438258c3d87d1ee5f721743a
ExpoWebBrowser: a212e6b480d8857d3e441fba51e0c968333803b3
+ EXStructuredHeaders: 09c70347b282e3d2507e25fb4c747b1b885f87f6
+ EXUpdates: 4fc73950af0af03388063823e75eb2f7566a48c9
EXUpdatesInterface: 7c977640bdd8b85833c19e3959ba46145c5719db
fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6
FBLazyVector: 7605ea4810e0e10ae4815292433c09bf4324ba45
@@ -3414,6 +3458,7 @@ SPEC CHECKSUMS:
RCTRequired: ca91e5dd26b64f577b528044c962baf171c6b716
RCTSearchApi: 5fc36140c598a74fd831dca924a28ed53bc7aa18
RCTTypeSafety: e7678bd60850ca5a41df9b8dc7154638cb66871f
+ ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda
React: 4641770499c39f45d4e7cde1eba30e081f9d8a3d
React-callinvoker: 4bef67b5c7f3f68db5929ab6a4d44b8a002998ea
React-Codegen: 4b8b4817cea7a54b83851d4c1f91f79aa73de30a
@@ -3531,6 +3576,6 @@ SPEC CHECKSUMS:
VisionCamera: f56eaedde0d3fa095143b78374d29e89e71735f9
Yoga: feb4910aba9742cfedc059e2b2902e22ffe9954a
-PODFILE CHECKSUM: aba718cdf38f663bc54e8d01365528918eea2b5a
+PODFILE CHECKSUM: 3debf6fbed3b6fecab16001c02c028737e11786c
COCOAPODS: 1.16.2
diff --git a/ios/Podfile.properties.json b/ios/Podfile.properties.json
index 42a5063d776..5dffd8bfae7 100644
--- a/ios/Podfile.properties.json
+++ b/ios/Podfile.properties.json
@@ -1,4 +1,5 @@
{
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
- "newArchEnabled": "true"
- }
+ "newArchEnabled": "true",
+ "expo.jsEngine": "hermes"
+}
diff --git a/jest.config.js b/jest.config.js
index 6fcc95ac404..ed306b27cce 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -71,6 +71,7 @@ const config = {
'/app/__mocks__/expo-apple-authentication.js',
'^expo-haptics(/.*)?$': '/app/__mocks__/expo-haptics.js',
'^expo-image$': '/app/__mocks__/expo-image.js',
+ '^expo-updates(/.*)?$': '/app/__mocks__/expo-updates.ts',
'^@metamask/design-system-react-native/dist/components/temp-components/Spinner/index.cjs$':
'/app/__mocks__/spinnerMock.js',
'^rive-react-native$': '/app/__mocks__/rive-react-native.tsx',
diff --git a/locales/languages/en.json b/locales/languages/en.json
index eb84e8b3b63..d4f5aa56eda 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -4421,6 +4421,8 @@
"order_status_completed": "Completed",
"order_status_failed": "Failed",
"order_status_cancelled": "Cancelled",
+ "webview_no_url_provided": "No URL was provided to continue",
+ "webview_error_no_address_provided": "No wallet address was provided to continue",
"onboarding": {
"what_to_expect": "What to Expect",
"quotes": "Our buy crypto feature aggregates quotes from integrated vendors, providing quotes from those sources to get crypto directly into your wallet with no waiting period.",
diff --git a/ota.config.js b/ota.config.js
new file mode 100644
index 00000000000..7bc1cae2d84
--- /dev/null
+++ b/ota.config.js
@@ -0,0 +1,51 @@
+/**
+ * OTA (Over-The-Air) Update Version Configuration
+ *
+ * SINGLE SOURCE OF TRUTH for all OTA and runtime versions.
+ *
+ * Used by:
+ * - app.config.js (Expo configuration)
+ * - scripts/update-expo-channel.js (Build script)
+ * - app/constants/ota.ts (App runtime - TypeScript re-exports)
+ *
+ * Workflow:
+ * 1. For OTA updates (JS-only): Increment OTA_VERSION only
+ * 2. For native releases: Bump version in package.json and reset OTA_VERSION to v0
+ */
+
+/* eslint-disable import/no-commonjs */
+const packageJson = require('./package.json');
+
+/**
+ * Current OTA update version
+ * Increment with each OTA update: v0 -> v1 -> v2 -> v3 etc.
+ * Reset to v0 when releasing a new native build
+ */
+const OTA_VERSION = 'v0';
+
+/**
+ * Runtime version for native compatibility
+ * Automatically derived from package.json version
+ * Only changes when you bump the version in package.json (native releases)
+ */
+const RUNTIME_VERSION = packageJson.version;
+
+/**
+ * Expo Project ID
+ * The unique identifier for the Expo project
+ * Loaded from environment variables
+ */
+const PROJECT_ID = process.env.EXPO_PROJECT_ID || '';
+
+/**
+ * Expo Updates URL
+ * The URL endpoint for fetching OTA updates
+ */
+const UPDATE_URL = `https://u.expo.dev/${PROJECT_ID}`;
+
+module.exports = {
+ OTA_VERSION,
+ RUNTIME_VERSION,
+ PROJECT_ID,
+ UPDATE_URL,
+};
diff --git a/package.json b/package.json
index 1ce8cf93ede..6626d2d1ca4 100644
--- a/package.json
+++ b/package.json
@@ -362,6 +362,7 @@
"events": "3.0.0",
"expo": "~52.0.47",
"expo-apple-authentication": "~7.1.3",
+ "expo-asset": "~11.0.5",
"expo-auth-session": "~6.0.3",
"expo-build-properties": "~0.13.2",
"expo-dev-client": "~5.0.18",
@@ -369,6 +370,7 @@
"expo-haptics": "~14.0.1",
"expo-image": "~2.0.7",
"expo-sensors": "~14.0.2",
+ "expo-updates": "~0.27.4",
"fast-equals": "^5.2.2",
"fuse.js": "3.4.4",
"he": "^1.2.0",
diff --git a/scripts/build.sh b/scripts/build.sh
index d6fe322c5b9..84be116b565 100755
--- a/scripts/build.sh
+++ b/scripts/build.sh
@@ -799,7 +799,11 @@ if [ -z "$METAMASK_ENVIRONMENT" ]; then
exit 1
else
echo "METAMASK_ENVIRONMENT is set to: $METAMASK_ENVIRONMENT"
+
fi
+ # Update Expo channel configuration based on environment
+ echo "Updating Expo channel configuration..."
+ node "${__DIRNAME__}/update-expo-channel.js"
if [ "$PLATFORM" == "ios" ]; then
# we don't care about env file in CI
diff --git a/scripts/update-expo-channel.js b/scripts/update-expo-channel.js
new file mode 100755
index 00000000000..00095da0612
--- /dev/null
+++ b/scripts/update-expo-channel.js
@@ -0,0 +1,286 @@
+#!/usr/bin/env node
+
+/**
+ * Updates expo updates enable flag in Android and iOS plists
+ * based on the METAMASK_ENVIRONMENT environment variable.
+ *
+ * Usage: node scripts/update-expo-channel.js
+ * Or set as a prebuild step in your build process
+ */
+
+const fs = require('fs');
+const path = require('path');
+const { RUNTIME_VERSION, UPDATE_URL } = require('../ota.config.js');
+
+const VALID_ENVIRONMENTS = ['beta', 'rc', 'exp', 'test', 'e2e', 'dev', 'production'];
+
+const ANDROID_MANIFEST_PATH = path.join(__dirname, '..', 'android', 'app', 'src', 'main', 'AndroidManifest.xml');
+const IOS_EXPO_PLIST_PATH = path.join(__dirname, '..', 'ios', 'Expo.plist');
+
+//TODO: add production channel when it's ready
+const CONFIG_MAP = {
+ rc: {
+ channel: 'preview',
+ runtimeVersion: RUNTIME_VERSION,
+ updatesEnabled: false,
+ updateUrl: UPDATE_URL,
+ },
+};
+
+// Official Expo Updates configuration keys
+// Reference: https://docs.expo.dev/versions/latest/sdk/updates/#configuration
+const EXPO_CONFIG_MAP = {
+ enabled: {
+ ios: 'EXUpdatesEnabled',
+ android: 'expo.modules.updates.ENABLED'
+ },
+ url: {
+ ios: 'EXUpdatesURL',
+ android: 'expo.modules.updates.EXPO_UPDATE_URL'
+ },
+ runtimeVersion: {
+ ios: 'EXUpdatesRuntimeVersion',
+ android: 'expo.modules.updates.EXPO_RUNTIME_VERSION'
+ },
+ requestHeaders: {
+ ios: 'EXUpdatesRequestHeaders',
+ android: 'expo.modules.updates.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY'
+ },
+ checkAutomatically: {
+ ios: 'EXUpdatesCheckOnLaunch',
+ android: 'expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH'
+ }
+};
+
+/**
+ * Gets the configuration for a given environment
+ * @returns {Object} - The configuration object with channel, runtimeVersion, and updatesEnabled
+ */
+function getConfigForEnvironment() {
+ // For now, all environments use RC configuration
+ return CONFIG_MAP.rc;
+}
+
+/**
+ * Only toggles EXPO_UPDATES_CONFIGURATION_ENABLED in AndroidManifest.xml
+ * @param {string} filePath
+ * @param {string} channelName
+ * @param {string} runtimeVersion
+ * @param {boolean} updatesEnabled
+ * @param {string} updateUrl
+ */
+function updateAndroidManifest(filePath, channelName, runtimeVersion, updatesEnabled, updateUrl) {
+ let content = fs.readFileSync(filePath, 'utf8');
+
+ // Update or insert UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY (JSON header with channel)
+ const requestHeadersKey = EXPO_CONFIG_MAP.requestHeaders.android;
+ if (content.includes(requestHeadersKey)) {
+ content = content.replace(
+ //g,
+ ``
+ );
+ } else {
+ content = content.replace(
+ /(\s*)<\/application>/,
+ `\n\t\t\n\t\t$1`
+ );
+ }
+
+ // Update or insert EXPO_RUNTIME_VERSION
+ const runtimeVersionKey = EXPO_CONFIG_MAP.runtimeVersion.android;
+ if (content.includes(runtimeVersionKey)) {
+ content = content.replace(
+ //g,
+ ``
+ );
+ } else {
+ content = content.replace(
+ /(\s*)<\/application>/,
+ `\n\t\t$1`
+ );
+ }
+
+ // Update or insert EXPO_UPDATE_URL
+ const updateUrlKey = EXPO_CONFIG_MAP.url.android;
+ if (content.includes(updateUrlKey)) {
+ content = content.replace(
+ //g,
+ ``
+ );
+ } else {
+ content = content.replace(
+ /(\s*)<\/application>/,
+ `\n\t\t$1`
+ );
+ }
+
+ // Only toggle expo.modules.updates.ENABLED; rely on defaults for the rest
+ const enabledKey = EXPO_CONFIG_MAP.enabled.android;
+ const enabledValue = updatesEnabled ? 'true' : 'false';
+ if (content.includes(enabledKey)) {
+ content = content.replace(
+ //g,
+ ``
+ );
+ } else {
+ content = content.replace(
+ /(\s*)<\/application>/,
+ `\n\t\t$1`
+ );
+ }
+
+ fs.writeFileSync(filePath, content, 'utf8');
+ console.log('✓ AndroidManifest.xml updated successfully');
+}
+
+/**
+ * Only toggles EXUpdatesEnabled in plist file
+ * @param {string} filePath
+ * @param {string} channelName
+ * @param {string} runtimeVersion
+ * @param {string} fileName
+ * @param {boolean} updatesEnabled
+ * @param {string} updateUrl
+ */
+function updatePlistFile(filePath, channelName, runtimeVersion, fileName, updatesEnabled, updateUrl) {
+ console.log(`Updating ${fileName}: channel=${channelName}, runtime=${runtimeVersion}, EXUpdatesEnabled=${updatesEnabled}, updateUrl=${updateUrl}`);
+
+ let content = fs.readFileSync(filePath, 'utf8');
+
+ // Update or insert EXUpdatesRuntimeVersion
+ const runtimeVersionKey = EXPO_CONFIG_MAP.runtimeVersion.ios;
+ if (content.includes(`${runtimeVersionKey}`)) {
+ content = content.replace(
+ new RegExp(`(${runtimeVersionKey}<\\/key>\\s*)[^<]*(<\\/string>)`),
+ `$1${runtimeVersion}$2`
+ );
+ } else {
+ content = content.replace(
+ /(\s*)<\/dict>\s*<\/plist>/,
+ `\n\t${runtimeVersionKey}\n\t${runtimeVersion}$1\n`
+ );
+ }
+
+ // Update or insert EXUpdatesURL
+ const updateUrlKey = EXPO_CONFIG_MAP.url.ios;
+ if (content.includes(`${updateUrlKey}`)) {
+ content = content.replace(
+ new RegExp(`(${updateUrlKey}<\\/key>\\s*)[^<]*(<\\/string>)`),
+ `$1${updateUrl}$2`
+ );
+ } else {
+ content = content.replace(
+ /(\s*)<\/dict>\s*<\/plist>/,
+ `\n\t${updateUrlKey}\n\t${updateUrl}$1\n`
+ );
+ }
+
+ // Update or insert EXUpdatesRequestHeaders.expo-channel-name
+ const requestHeadersKey = EXPO_CONFIG_MAP.requestHeaders.ios;
+ if (content.includes(`${requestHeadersKey}`)) {
+ if (content.includes('expo-channel-name')) {
+ content = content.replace(
+ /(expo-channel-name<\/key>\s*)[^<]*(<\/string>)/,
+ `$1${channelName}$2`
+ );
+ } else {
+ content = content.replace(
+ new RegExp(`(${requestHeadersKey}<\\/key>\\s*)`),
+ `$1\n\t\texpo-channel-name\n\t\t${channelName}`
+ );
+ }
+ } else {
+ content = content.replace(
+ /(\s*)<\/dict>\s*<\/plist>/,
+ `\n\t${requestHeadersKey}\n\t\n\t\texpo-channel-name\n\t\t${channelName}\n\t$1\n`
+ );
+ }
+
+ // Only toggle EXUpdatesEnabled; do not modify CheckOnLaunch or LaunchWaitMs
+ const enabledKey = EXPO_CONFIG_MAP.enabled.ios;
+ if (content.includes(`${enabledKey}`)) {
+ content = content.replace(
+ new RegExp(`${enabledKey}<\\/key>\\s*<(true|false)\\/>`),
+ `${enabledKey}\n\t<${updatesEnabled ? 'true' : 'false'}/>`
+ );
+ } else {
+ content = content.replace(
+ /(\s*)<\/dict>\s*<\/plist>/,
+ `\n\t${enabledKey}\n\t<${updatesEnabled ? 'true' : 'false'}/>\n$1\n`
+ );
+ }
+
+ fs.writeFileSync(filePath, content, 'utf8');
+ console.log(`✓ ${fileName} updated successfully`);
+}
+
+/**
+ * Main function
+ */
+function main() {
+ const environment = process.env.METAMASK_ENVIRONMENT;
+
+ console.log('======================================');
+ console.log(' Updating Expo Updates Configuration');
+ console.log('======================================');
+ console.log('');
+
+ // Validate environment variable
+ if (!environment) {
+ console.error('❌ Error: METAMASK_ENVIRONMENT is not set');
+ console.error(' Please set it to one of:', VALID_ENVIRONMENTS.join(', '));
+ process.exit(1);
+ }
+
+ if (!VALID_ENVIRONMENTS.includes(environment)) {
+ console.error(`❌ Error: Invalid METAMASK_ENVIRONMENT: ${environment}`);
+ console.error(' Valid values:', VALID_ENVIRONMENTS.join(', '));
+ process.exit(1);
+ }
+
+ console.log(`Environment: ${environment}`);
+
+ // Skip configuration for production environment
+ if (environment === 'production') {
+ console.log('ℹ️ Production environment detected - skipping Expo Updates configuration');
+ console.log('✓ No configuration changes made');
+ return;
+ }
+
+ // Get configuration for this environment
+ const { channel, runtimeVersion, updatesEnabled, updateUrl } = getConfigForEnvironment(environment);
+
+ // Check if files exist
+ if (!fs.existsSync(ANDROID_MANIFEST_PATH)) {
+ console.error(`❌ Error: AndroidManifest.xml not found at ${ANDROID_MANIFEST_PATH}`);
+ process.exit(1);
+ }
+
+ if (!fs.existsSync(IOS_EXPO_PLIST_PATH)) {
+ console.error(`❌ Error: Expo.plist not found at ${IOS_EXPO_PLIST_PATH}`);
+ process.exit(1);
+ }
+
+
+ try {
+ updateAndroidManifest(ANDROID_MANIFEST_PATH, channel, runtimeVersion, updatesEnabled, updateUrl);
+ updatePlistFile(IOS_EXPO_PLIST_PATH, channel, runtimeVersion, 'Expo.plist', updatesEnabled, updateUrl);
+
+ console.log('✓ All files updated successfully!');
+ } catch (error) {
+ console.error('❌ Error updating files:', error.message);
+ process.exit(1);
+ }
+}
+
+if (require.main === module) {
+ main();
+}
+
+module.exports = {
+ getConfigForEnvironment,
+ updateAndroidManifest,
+ updatePlistFile,
+ CONFIG_MAP,
+};
+
diff --git a/wdio/screen-objects/WalletMainScreen.js b/wdio/screen-objects/WalletMainScreen.js
index 872711a6c38..0fec6530257 100644
--- a/wdio/screen-objects/WalletMainScreen.js
+++ b/wdio/screen-objects/WalletMainScreen.js
@@ -127,6 +127,30 @@ class WalletMainScreen {
return Selectors.getXpathElementByText('Localhost 8545 now active.');
}
+ get totalBalanceText() {
+ if (!this._device) {
+ return Selectors.getXpathElementByResourceId(WalletViewSelectorsIDs.TOTAL_BALANCE_TEXT);
+ } else {
+ return AppwrightSelectors.getElementByID(this._device, WalletViewSelectorsIDs.TOTAL_BALANCE_TEXT);
+ }
+ }
+
+ get balanceContainer() {
+ if (!this._device) {
+ return Selectors.getXpathElementByResourceId('balance-container');
+ } else {
+ return AppwrightSelectors.getElementByID(this._device, 'balance-container');
+ }
+ }
+
+ get tokenBalancesLoadedMarker() {
+ if (!this._device) {
+ return Selectors.getXpathElementByResourceId('token-balances-loaded-marker');
+ } else {
+ return AppwrightSelectors.getElementByID(this._device, 'token-balances-loaded-marker');
+ }
+ }
+
async tapImportTokensButton() {
const importToken = await this.ImportToken;
await importToken.waitForDisplayed();
@@ -148,7 +172,23 @@ class WalletMainScreen {
}
async tapNFTTab() {
- await Gestures.tapTextByXpath('NFTs');
+ if (!this._device) {
+ await Gestures.tapTextByXpath('NFTs');
+ } else {
+ // For Appwright, tap by text
+ const nftTabText = AppwrightSelectors.getElementByText(this._device, 'NFTs');
+ await AppwrightGestures.tap(nftTabText);
+ }
+ }
+
+ async tapTokensTab() {
+ if (!this._device) {
+ await Gestures.tapTextByXpath('Tokens');
+ } else {
+ // For Appwright, tap by text
+ const tokensTabText = AppwrightSelectors.getElementByText(this._device, 'Tokens');
+ await AppwrightGestures.tap(tokensTabText);
+ }
}
async tapOnToken(token) {
diff --git a/yarn.lock b/yarn.lock
index 7ca213d0d9a..f1059d1eaa3 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3728,7 +3728,7 @@ __metadata:
languageName: node
linkType: hard
-"@expo/code-signing-certificates@npm:^0.0.5":
+"@expo/code-signing-certificates@npm:0.0.5, @expo/code-signing-certificates@npm:^0.0.5":
version: 0.0.5
resolution: "@expo/code-signing-certificates@npm:0.0.5"
dependencies:
@@ -20899,6 +20899,13 @@ __metadata:
languageName: node
linkType: hard
+"arg@npm:4.1.0":
+ version: 4.1.0
+ resolution: "arg@npm:4.1.0"
+ checksum: 10/dc0e1ea7f0adee7871c456bd57f06fb9f8c2ccd91fd0537c73b66f3fa0c9697ccdfc25b358a417a3ab263c062aac0ef2df3a5523433861fe6277cb2ff769a9bc
+ languageName: node
+ linkType: hard
+
"arg@npm:^4.1.0":
version: 4.1.3
resolution: "arg@npm:4.1.3"
@@ -27815,6 +27822,13 @@ __metadata:
languageName: node
linkType: hard
+"expo-eas-client@npm:~0.13.3":
+ version: 0.13.3
+ resolution: "expo-eas-client@npm:0.13.3"
+ checksum: 10/6f37a14364a746915e4766d87924ae1c2c817a4115e16c5ae2d8f637d7780cabe297627a4d87ba60dd644352d874de961244363bd97fb65114415043c4ab4f88
+ languageName: node
+ linkType: hard
+
"expo-file-system@npm:~18.0.12, expo-file-system@npm:~18.0.7":
version: 18.0.12
resolution: "expo-file-system@npm:18.0.12"
@@ -27893,7 +27907,7 @@ __metadata:
languageName: node
linkType: hard
-"expo-manifests@npm:~0.15.8":
+"expo-manifests@npm:~0.15.7, expo-manifests@npm:~0.15.8":
version: 0.15.8
resolution: "expo-manifests@npm:0.15.8"
dependencies:
@@ -27944,6 +27958,13 @@ __metadata:
languageName: node
linkType: hard
+"expo-structured-headers@npm:~4.0.0":
+ version: 4.0.0
+ resolution: "expo-structured-headers@npm:4.0.0"
+ checksum: 10/1a98dded51678155606f92af27d5fab6afe35d342ee961ad9bf669f66126b6ff3d321100b684ef0cf1552470682ab4e52ed93ef53d2d5a28299611ddfbf25417
+ languageName: node
+ linkType: hard
+
"expo-updates-interface@npm:~1.0.0":
version: 1.0.0
resolution: "expo-updates-interface@npm:1.0.0"
@@ -27953,6 +27974,33 @@ __metadata:
languageName: node
linkType: hard
+"expo-updates@npm:~0.27.4":
+ version: 0.27.4
+ resolution: "expo-updates@npm:0.27.4"
+ dependencies:
+ "@expo/code-signing-certificates": "npm:0.0.5"
+ "@expo/config": "npm:~10.0.11"
+ "@expo/config-plugins": "npm:~9.0.17"
+ "@expo/spawn-async": "npm:^1.7.2"
+ arg: "npm:4.1.0"
+ chalk: "npm:^4.1.2"
+ expo-eas-client: "npm:~0.13.3"
+ expo-manifests: "npm:~0.15.7"
+ expo-structured-headers: "npm:~4.0.0"
+ expo-updates-interface: "npm:~1.0.0"
+ fast-glob: "npm:^3.3.2"
+ fbemitter: "npm:^3.0.0"
+ ignore: "npm:^5.3.1"
+ resolve-from: "npm:^5.0.0"
+ peerDependencies:
+ expo: "*"
+ react: "*"
+ bin:
+ expo-updates: bin/cli.js
+ checksum: 10/ea72f84525305ed0c32cafdec60b215bfd769236ecba1646bed1a647e3eb78a752853ffbe128b6365b6d7d87ddde738e377e3c9396a1688da36d7ccc1e1a182f
+ languageName: node
+ linkType: hard
+
"expo-web-browser@npm:~14.0.2":
version: 14.0.2
resolution: "expo-web-browser@npm:14.0.2"
@@ -34483,6 +34531,7 @@ __metadata:
execa: "npm:^8.0.1"
expo: "npm:~52.0.47"
expo-apple-authentication: "npm:~7.1.3"
+ expo-asset: "npm:~11.0.5"
expo-auth-session: "npm:~6.0.3"
expo-build-properties: "npm:~0.13.2"
expo-dev-client: "npm:~5.0.18"
@@ -34490,6 +34539,7 @@ __metadata:
expo-haptics: "npm:~14.0.1"
expo-image: "npm:~2.0.7"
expo-sensors: "npm:~14.0.2"
+ expo-updates: "npm:~0.27.4"
fast-equals: "npm:^5.2.2"
fs-extra: "npm:^10.1.0"
fuse.js: "npm:3.4.4"