diff --git a/.storybook/storybook.requires.js b/.storybook/storybook.requires.js
index 6857483777a..e532ebcb742 100644
--- a/.storybook/storybook.requires.js
+++ b/.storybook/storybook.requires.js
@@ -48,6 +48,7 @@ const getStories = () => {
"./app/component-library/components-temp/Buttons/ButtonToggle/ButtonToggle.stories.tsx": require("../app/component-library/components-temp/Buttons/ButtonToggle/ButtonToggle.stories.tsx"),
"./app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.stories.tsx": require("../app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.stories.tsx"),
"./app/component-library/components-temp/HeaderCenter/HeaderCenter.stories.tsx": require("../app/component-library/components-temp/HeaderCenter/HeaderCenter.stories.tsx"),
+ "./app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.stories.tsx": require("../app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.stories.tsx"),
"./app/component-library/components-temp/KeyValueRow/KeyValueRow.stories.tsx": require("../app/component-library/components-temp/KeyValueRow/KeyValueRow.stories.tsx"),
"./app/component-library/components-temp/ListItemMultiSelectButton/ListItemMultiSelectButton.stories.tsx": require("../app/component-library/components-temp/ListItemMultiSelectButton/ListItemMultiSelectButton.stories.tsx"),
"./app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.stories.tsx": require("../app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.stories.tsx"),
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 4f06959781d..a66dba67dd7 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -371,7 +371,6 @@ android {
dependencies {
// The version of react-native is set by the React Native Gradle Plugin
implementation(files("../libs/ecies.aar"))
- implementation(files("../libs/nativesdk.aar"))
implementation("com.facebook.react:react-android")
implementation 'org.apache.commons:commons-compress:1.22'
androidTestImplementation 'androidx.test:core:1.5.0'
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index e398c99dbf1..9d30b90cb86 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -184,11 +184,5 @@
android:resource="@xml/filepaths"
/>
-
-
-
diff --git a/android/app/src/main/java/io/metamask/MainApplication.kt b/android/app/src/main/java/io/metamask/MainApplication.kt
index cc96024a9a7..442d52188e3 100644
--- a/android/app/src/main/java/io/metamask/MainApplication.kt
+++ b/android/app/src/main/java/io/metamask/MainApplication.kt
@@ -27,7 +27,6 @@ import cl.json.ShareApplication
import io.branch.rnbranch.RNBranchModule
import io.metamask.nativeModules.PreventScreenshotPackage
import io.metamask.nativeModules.RCTMinimizerPackage
-import io.metamask.nativesdk.NativeSDKPackage
import io.metamask.nativeModules.RNTar.RNTarPackage
import io.metamask.nativeModules.NotificationPackage
@@ -43,9 +42,8 @@ class MainApplication : Application(), ShareApplication, ReactApplication {
// Add all our custom packages
packages.add(PreventScreenshotPackage())
packages.add(RCTMinimizerPackage())
- packages.add(NativeSDKPackage())
packages.add(RNTarPackage())
- packages.add(NotificationPackage())
+ packages.add(NotificationPackage())
return packages
}
diff --git a/android/libs/nativesdk.aar b/android/libs/nativesdk.aar
deleted file mode 100644
index 990866e39ab..00000000000
Binary files a/android/libs/nativesdk.aar and /dev/null differ
diff --git a/app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.stories.tsx b/app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.stories.tsx
new file mode 100644
index 00000000000..1c9b6419bfe
--- /dev/null
+++ b/app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.stories.tsx
@@ -0,0 +1,126 @@
+/* eslint-disable no-console */
+import React from 'react';
+
+import {
+ Box,
+ Text,
+ TextVariant,
+ IconName,
+} from '@metamask/design-system-react-native';
+
+import HeaderWithTitleLeft from './HeaderWithTitleLeft';
+
+const HeaderWithTitleLeftMeta = {
+ title: 'Components Temp / HeaderWithTitleLeft',
+ component: HeaderWithTitleLeft,
+ argTypes: {
+ twClassName: {
+ control: 'text',
+ },
+ },
+};
+
+export default HeaderWithTitleLeftMeta;
+
+const SampleNFTImage = () => (
+
+ NFT
+
+);
+
+export const Default = {
+ args: {
+ titleLeftProps: {
+ topLabel: 'Send',
+ title: '$4.42',
+ },
+ },
+};
+
+export const OnBack = {
+ render: () => (
+ console.log('Back pressed')}
+ titleLeftProps={{
+ topLabel: 'Send',
+ title: '$4.42',
+ endAccessory: ,
+ }}
+ />
+ ),
+};
+
+export const WithBottomLabel = {
+ render: () => (
+ console.log('Back pressed')}
+ titleLeftProps={{
+ topLabel: 'Send',
+ title: '$4.42',
+ bottomLabel: '0.002 ETH',
+ endAccessory: ,
+ }}
+ />
+ ),
+};
+
+export const EndButtonIconProps = {
+ render: () => (
+ console.log('Back pressed')}
+ endButtonIconProps={[
+ {
+ iconName: IconName.Close,
+ onPress: () => console.log('Close pressed'),
+ },
+ ]}
+ titleLeftProps={{
+ topLabel: 'Send',
+ title: '$4.42',
+ endAccessory: ,
+ }}
+ />
+ ),
+};
+
+export const BackButtonProps = {
+ render: () => (
+ console.log('Custom back pressed'),
+ }}
+ titleLeftProps={{
+ topLabel: 'Receive',
+ title: '$1,234.56',
+ }}
+ />
+ ),
+};
+
+export const TitleLeft = {
+ render: () => (
+ console.log('Back pressed')}
+ titleLeft={
+
+ Custom Title Section
+
+ This is a completely custom title section
+
+
+ }
+ />
+ ),
+};
+
+export const NoBackButton = {
+ render: () => (
+
+ ),
+};
diff --git a/app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.test.tsx b/app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.test.tsx
new file mode 100644
index 00000000000..406c8e08553
--- /dev/null
+++ b/app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.test.tsx
@@ -0,0 +1,218 @@
+// Third party dependencies.
+import React from 'react';
+import { render, fireEvent } from '@testing-library/react-native';
+import { Text, IconName } from '@metamask/design-system-react-native';
+
+// Internal dependencies.
+import HeaderWithTitleLeft from './HeaderWithTitleLeft';
+
+const TEST_IDS = {
+ CONTAINER: 'header-with-title-left-container',
+ TITLE_SECTION: 'header-with-title-left-title-section',
+ BACK_BUTTON: 'header-with-title-left-back-button',
+ TITLE_LEFT: 'title-left',
+ HEADER_BASE_END_ACCESSORY: 'header-base-end-accessory',
+};
+
+describe('HeaderWithTitleLeft', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('rendering', () => {
+ it('renders container with correct testID', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId(TEST_IDS.CONTAINER)).toBeOnTheScreen();
+ });
+
+ it('renders title section when titleLeftProps provided', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId(TEST_IDS.TITLE_SECTION)).toBeOnTheScreen();
+ });
+
+ it('renders TitleLeft with props when titleLeftProps provided', () => {
+ const { getByText, getByTestId } = render(
+ ,
+ );
+
+ expect(getByText('Send')).toBeOnTheScreen();
+ expect(getByText('$4.42')).toBeOnTheScreen();
+ expect(getByTestId(TEST_IDS.TITLE_LEFT)).toBeOnTheScreen();
+ });
+
+ it('renders custom titleLeft node when provided', () => {
+ const { getByText } = render(
+ Custom Title Section} />,
+ );
+
+ expect(getByText('Custom Title Section')).toBeOnTheScreen();
+ });
+
+ it('titleLeft takes priority over titleLeftProps', () => {
+ const { getByText, queryByText } = render(
+ Custom Node}
+ titleLeftProps={{ title: 'Props Title' }}
+ />,
+ );
+
+ expect(getByText('Custom Node')).toBeOnTheScreen();
+ expect(queryByText('Props Title')).toBeNull();
+ });
+
+ it('does not render title section when neither titleLeft nor titleLeftProps provided', () => {
+ const { queryByTestId } = render(
+ ,
+ );
+
+ expect(queryByTestId(TEST_IDS.TITLE_SECTION)).toBeNull();
+ });
+ });
+
+ describe('back button', () => {
+ it('renders back button when onBack provided', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId(TEST_IDS.BACK_BUTTON)).toBeOnTheScreen();
+ });
+
+ it('renders back button when backButtonProps provided', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId(TEST_IDS.BACK_BUTTON)).toBeOnTheScreen();
+ });
+
+ it('calls onBack when back button pressed', () => {
+ const onBack = jest.fn();
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.press(getByTestId(TEST_IDS.BACK_BUTTON));
+
+ expect(onBack).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls backButtonProps.onPress when back button pressed', () => {
+ const onPress = jest.fn();
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.press(getByTestId(TEST_IDS.BACK_BUTTON));
+
+ expect(onPress).toHaveBeenCalledTimes(1);
+ });
+
+ it('backButtonProps.onPress takes priority over onBack', () => {
+ const onBack = jest.fn();
+ const onPress = jest.fn();
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.press(getByTestId(TEST_IDS.BACK_BUTTON));
+
+ expect(onPress).toHaveBeenCalledTimes(1);
+ expect(onBack).not.toHaveBeenCalled();
+ });
+
+ it('does not render back button when neither onBack nor backButtonProps provided', () => {
+ const { queryByLabelText } = render(
+ ,
+ );
+
+ expect(queryByLabelText('Arrow Left')).toBeNull();
+ });
+ });
+
+ describe('props forwarding', () => {
+ it('forwards endButtonIconProps to HeaderBase', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId(TEST_IDS.HEADER_BASE_END_ACCESSORY)).toBeOnTheScreen();
+ });
+
+ it('accepts custom testID', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId('custom-header')).toBeOnTheScreen();
+ });
+
+ it('forwards startButtonIconProps directly when provided', () => {
+ const onPress = jest.fn();
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId('custom-start-button')).toBeOnTheScreen();
+ });
+ });
+});
diff --git a/app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.tsx b/app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.tsx
new file mode 100644
index 00000000000..6059575b75e
--- /dev/null
+++ b/app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.tsx
@@ -0,0 +1,94 @@
+// Third party dependencies.
+import React, { useMemo } from 'react';
+
+// External dependencies.
+import {
+ Box,
+ IconName,
+ ButtonIconProps,
+} from '@metamask/design-system-react-native';
+
+// Internal dependencies.
+import HeaderBase from '../../components/HeaderBase';
+import TitleLeft from '../TitleLeft';
+import { HeaderWithTitleLeftProps } from './HeaderWithTitleLeft.types';
+
+/**
+ * HeaderWithTitleLeft is a header component that combines HeaderBase (with back button)
+ * on top and a TitleLeft section below it.
+ *
+ * @example
+ * ```tsx
+ *
+ * }}
+ * />
+ * ```
+ */
+const HeaderWithTitleLeft: React.FC = ({
+ onBack,
+ backButtonProps,
+ titleLeft,
+ titleLeftProps,
+ startButtonIconProps,
+ twClassName,
+ testID,
+ titleSectionTestID,
+ ...headerBaseProps
+}) => {
+ // Build startButtonIconProps with back button if onBack or backButtonProps is provided
+ const resolvedStartButtonIconProps = useMemo(() => {
+ if (startButtonIconProps) {
+ // If startButtonIconProps is explicitly provided, use it as-is
+ return startButtonIconProps;
+ }
+
+ if (onBack || backButtonProps) {
+ const backProps: ButtonIconProps = {
+ iconName: IconName.ArrowLeft,
+ ...(backButtonProps || {}),
+ onPress: backButtonProps?.onPress ?? onBack,
+ };
+ return backProps;
+ }
+
+ return undefined;
+ }, [startButtonIconProps, onBack, backButtonProps]);
+
+ // Render title section content
+ const renderTitleSection = () => {
+ if (titleLeft) {
+ return titleLeft;
+ }
+ if (titleLeftProps) {
+ return ;
+ }
+ return null;
+ };
+
+ const hasTitleSection = titleLeft || titleLeftProps;
+
+ const resolvedTwClassName = twClassName ? `px-2 ${twClassName}` : 'px-2';
+
+ return (
+
+ {/* HeaderBase section */}
+
+
+ {/* TitleLeft section */}
+ {hasTitleSection && (
+ {renderTitleSection()}
+ )}
+
+ );
+};
+
+export default HeaderWithTitleLeft;
diff --git a/app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.types.ts b/app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.types.ts
new file mode 100644
index 00000000000..97804f07b0a
--- /dev/null
+++ b/app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.types.ts
@@ -0,0 +1,40 @@
+// Third party dependencies.
+import { ReactNode } from 'react';
+
+// External dependencies.
+import { ButtonIconProps } from '@metamask/design-system-react-native';
+
+// Internal dependencies.
+import { HeaderBaseProps } from '../../components/HeaderBase';
+import { TitleLeftProps } from '../TitleLeft/TitleLeft.types';
+
+/**
+ * HeaderWithTitleLeft component props.
+ */
+export interface HeaderWithTitleLeftProps
+ extends Omit {
+ /**
+ * Callback when the back button is pressed.
+ * If provided, a back button will be rendered as startAccessory.
+ */
+ onBack?: () => void;
+ /**
+ * Additional props to pass to the back ButtonIcon.
+ * If provided, a back button will be rendered with these props spread.
+ */
+ backButtonProps?: Omit;
+ /**
+ * Custom node to render in the title section.
+ * If provided, takes priority over titleLeftProps.
+ */
+ titleLeft?: ReactNode;
+ /**
+ * Props to pass to the TitleLeft component.
+ * Only used if titleLeft is not provided.
+ */
+ titleLeftProps?: TitleLeftProps;
+ /**
+ * Test ID for the title section wrapper.
+ */
+ titleSectionTestID?: string;
+}
diff --git a/app/component-library/components-temp/HeaderWithTitleLeft/index.ts b/app/component-library/components-temp/HeaderWithTitleLeft/index.ts
new file mode 100644
index 00000000000..6a5de15f41f
--- /dev/null
+++ b/app/component-library/components-temp/HeaderWithTitleLeft/index.ts
@@ -0,0 +1,2 @@
+export { default } from './HeaderWithTitleLeft';
+export type { HeaderWithTitleLeftProps } from './HeaderWithTitleLeft.types';
diff --git a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx
index c0f312c5774..d925339c351 100644
--- a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx
+++ b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx
@@ -43,9 +43,7 @@ import { useSelector } from 'react-redux';
import React from 'react';
import CardHome from './CardHome';
import { cardDefaultNavigationOptions } from '../../routes';
-import renderWithProvider, {
- renderScreen,
-} from '../../../../../util/test/renderWithProvider';
+import { renderScreen } from '../../../../../util/test/renderWithProvider';
import { withCardSDK } from '../../sdk';
import { backgroundState } from '../../../../../util/test/initial-root-state';
import Routes from '../../../../../constants/navigation/Routes';
@@ -2497,7 +2495,7 @@ describe('CardHome Component', () => {
});
});
- it('does nothing when no error exists', async () => {
+ it('does nothing when no error exists', () => {
// Given: authenticated user without error
setupMockSelectors({ isAuthenticated: true });
mockIsAuthenticationError.mockReturnValue(false);
@@ -2510,7 +2508,6 @@ describe('CardHome Component', () => {
render();
// Then: should not trigger authentication error handling
- await new Promise((r) => setTimeout(r, 100));
expect(mockRemoveCardBaanxToken).not.toHaveBeenCalled();
expect(mockResetAuthenticatedData).not.toHaveBeenCalled();
expect(mockClearAllCache).not.toHaveBeenCalled();
@@ -2519,7 +2516,7 @@ describe('CardHome Component', () => {
);
});
- it('does nothing when user is not authenticated', async () => {
+ it('does nothing when user is not authenticated', () => {
// Given: non-authenticated user with error
setupMockSelectors({ isAuthenticated: false });
mockIsAuthenticationError.mockReturnValue(false);
@@ -2532,13 +2529,12 @@ describe('CardHome Component', () => {
render();
// Then: should not trigger authentication error handling
- await new Promise((r) => setTimeout(r, 100));
expect(mockRemoveCardBaanxToken).not.toHaveBeenCalled();
expect(mockResetAuthenticatedData).not.toHaveBeenCalled();
expect(mockClearAllCache).not.toHaveBeenCalled();
});
- it('does nothing when error is not an authentication error', async () => {
+ it('does nothing when error is not an authentication error', () => {
// Given: authenticated user with non-authentication error
setupMockSelectors({ isAuthenticated: true });
mockIsAuthenticationError.mockReturnValue(false);
@@ -2551,7 +2547,6 @@ describe('CardHome Component', () => {
render();
// Then: should not trigger authentication error handling
- await new Promise((r) => setTimeout(r, 100));
expect(mockRemoveCardBaanxToken).not.toHaveBeenCalled();
expect(mockResetAuthenticatedData).not.toHaveBeenCalled();
expect(mockClearAllCache).not.toHaveBeenCalled();
@@ -2668,8 +2663,8 @@ describe('CardHome Component', () => {
// Given: authenticated user with persistent authentication error
setupMockSelectors({ isAuthenticated: true });
mockIsAuthenticationError.mockReturnValue(true);
- const WrappedCardHome = withCardSDK(CardHome);
+ // Setup mock to return same error for multiple renders
setupLoadCardDataMock({
error: 'First auth error',
isAuthenticated: true,
@@ -2684,27 +2679,13 @@ describe('CardHome Component', () => {
priorityToken: mockPriorityToken,
});
- // When: component renders twice with the same authentication error
- const { rerender } = renderWithProvider(, {
- state: {
- engine: {
- backgroundState,
- },
- },
- });
+ // When: component renders with authentication error
+ render();
// Then: cleanup runs once on initial render
await waitFor(() => {
expect(mockRemoveCardBaanxToken).toHaveBeenCalledTimes(1);
});
-
- // When: component re-renders with same error
- rerender();
-
- // Then: cleanup does not run again for unchanged error
- await waitFor(() => {
- expect(mockRemoveCardBaanxToken).toHaveBeenCalledTimes(1);
- });
});
it('does not dispatch Redux actions if token removal throws and component unmounts', async () => {
diff --git a/app/components/UI/Card/Views/CardHome/CardHome.tsx b/app/components/UI/Card/Views/CardHome/CardHome.tsx
index 7649709f20c..9f677c27f34 100644
--- a/app/components/UI/Card/Views/CardHome/CardHome.tsx
+++ b/app/components/UI/Card/Views/CardHome/CardHome.tsx
@@ -23,7 +23,12 @@ import Icon, {
import Text, {
TextVariant,
} from '../../../../../component-library/components/Texts/Text';
-import { StackActions, useNavigation } from '@react-navigation/native';
+import {
+ StackActions,
+ useNavigation,
+ useRoute,
+ RouteProp,
+} from '@react-navigation/native';
import { useDispatch, useSelector } from 'react-redux';
import SensitiveText, {
SensitiveTextLength,
@@ -87,6 +92,13 @@ import SpendingLimitProgressBar from '../../components/SpendingLimitProgressBar/
import { createAddFundsModalNavigationDetails } from '../../components/AddFundsBottomSheet/AddFundsBottomSheet';
import { createAssetSelectionModalNavigationDetails } from '../../components/AssetSelectionBottomSheet/AssetSelectionBottomSheet';
+/**
+ * Route params for CardHome screen
+ */
+interface CardHomeRouteParams {
+ showDeeplinkToast?: boolean;
+}
+
/**
* CardHome Component
*
@@ -111,11 +123,14 @@ const CardHome = () => {
const isComponentUnmountedRef = useRef(false);
const hasShownKYCAlertRef = useRef(false);
const hasShownKYCErrorAlertRef = useRef(false);
+ const hasShownDeeplinkToast = useRef(false);
const [
isCloseSpendingLimitWarningShown,
setIsCloseSpendingLimitWarningShown,
] = useState(true);
+ const route =
+ useRoute>();
const { trackEvent, createEventBuilder } = useMetrics();
const navigation = useNavigation();
const dispatch = useDispatch();
@@ -260,6 +275,25 @@ const CardHome = () => {
isSDKLoading,
]);
+ // Show toast notification when navigating from deeplink
+ useEffect(() => {
+ if (
+ route.params?.showDeeplinkToast &&
+ !hasShownDeeplinkToast.current &&
+ toastRef?.current
+ ) {
+ hasShownDeeplinkToast.current = true;
+ toastRef.current.showToast({
+ variant: ToastVariants.Icon,
+ labelOptions: [
+ { label: strings('card.card_button_already_enabled_toast') },
+ ],
+ hasNoTimeout: false,
+ iconName: IconName.Info,
+ });
+ }
+ }, [route.params?.showDeeplinkToast, toastRef]);
+
const addFundsAction = useCallback(() => {
trackEvent(
createEventBuilder(MetaMetricsEvents.CARD_ADD_FUNDS_CLICKED).build(),
diff --git a/app/components/UI/Card/hooks/isBaanxLoginEnabled.test.ts b/app/components/UI/Card/hooks/isBaanxLoginEnabled.test.ts
index 45f06b57ba5..fb76237a534 100644
--- a/app/components/UI/Card/hooks/isBaanxLoginEnabled.test.ts
+++ b/app/components/UI/Card/hooks/isBaanxLoginEnabled.test.ts
@@ -8,16 +8,24 @@ jest.mock('react-redux', () => ({
const mockUseSelector = useSelector as jest.MockedFunction;
+interface MockSelectorsParams {
+ displayCardButtonFeatureFlag: boolean | null | undefined;
+ alwaysShowCardButton: boolean | null | undefined;
+ cardGeoLocation: string;
+ cardSupportedCountries: Record | null | undefined;
+}
+
const mockSelectors = ({
displayCardButtonFeatureFlag,
alwaysShowCardButton,
-}: {
- displayCardButtonFeatureFlag: boolean | null | undefined;
- alwaysShowCardButton: boolean | null | undefined;
-}) => {
+ cardGeoLocation,
+ cardSupportedCountries,
+}: MockSelectorsParams) => {
mockUseSelector
.mockReturnValueOnce(displayCardButtonFeatureFlag)
- .mockReturnValueOnce(alwaysShowCardButton);
+ .mockReturnValueOnce(alwaysShowCardButton)
+ .mockReturnValueOnce(cardGeoLocation)
+ .mockReturnValueOnce(cardSupportedCountries);
};
describe('useIsBaanxLoginEnabled', () => {
@@ -25,87 +33,176 @@ describe('useIsBaanxLoginEnabled', () => {
jest.clearAllMocks();
});
- it('returns false when both flags are null', () => {
- mockSelectors({
- displayCardButtonFeatureFlag: null,
- alwaysShowCardButton: null,
+ describe('when alwaysShowCardButton is enabled', () => {
+ it('returns true regardless of other flag states', () => {
+ mockSelectors({
+ displayCardButtonFeatureFlag: false,
+ alwaysShowCardButton: true,
+ cardGeoLocation: 'US',
+ cardSupportedCountries: { US: false },
+ });
+
+ const { result } = renderHook(() => useIsBaanxLoginEnabled());
+
+ expect(result.current).toBe(true);
});
- const { result } = renderHook(() => useIsBaanxLoginEnabled());
+ it('returns true even when country is not supported', () => {
+ mockSelectors({
+ displayCardButtonFeatureFlag: true,
+ alwaysShowCardButton: true,
+ cardGeoLocation: 'XX',
+ cardSupportedCountries: {},
+ });
- expect(result.current).toBe(false);
+ const { result } = renderHook(() => useIsBaanxLoginEnabled());
+
+ expect(result.current).toBe(true);
+ });
});
- it('returns false when both flags are undefined', () => {
- mockSelectors({
- displayCardButtonFeatureFlag: undefined,
- alwaysShowCardButton: undefined,
+ describe('when alwaysShowCardButton is disabled', () => {
+ it('returns true when country is supported and feature flag is enabled', () => {
+ mockSelectors({
+ displayCardButtonFeatureFlag: true,
+ alwaysShowCardButton: false,
+ cardGeoLocation: 'US',
+ cardSupportedCountries: { US: true },
+ });
+
+ const { result } = renderHook(() => useIsBaanxLoginEnabled());
+
+ expect(result.current).toBe(true);
});
- const { result } = renderHook(() => useIsBaanxLoginEnabled());
+ it('returns false when country is supported but feature flag is disabled', () => {
+ mockSelectors({
+ displayCardButtonFeatureFlag: false,
+ alwaysShowCardButton: false,
+ cardGeoLocation: 'US',
+ cardSupportedCountries: { US: true },
+ });
- expect(result.current).toBe(false);
- });
+ const { result } = renderHook(() => useIsBaanxLoginEnabled());
- it('returns false when both flags are disabled', () => {
- mockSelectors({
- displayCardButtonFeatureFlag: false,
- alwaysShowCardButton: false,
+ expect(result.current).toBe(false);
});
- const { result } = renderHook(() => useIsBaanxLoginEnabled());
+ it('returns false when feature flag is enabled but country is not supported', () => {
+ mockSelectors({
+ displayCardButtonFeatureFlag: true,
+ alwaysShowCardButton: false,
+ cardGeoLocation: 'XX',
+ cardSupportedCountries: { US: true, GB: true },
+ });
- expect(result.current).toBe(false);
- });
+ const { result } = renderHook(() => useIsBaanxLoginEnabled());
- it('returns true when feature flag is enabled and experimental switch is disabled', () => {
- mockSelectors({
- displayCardButtonFeatureFlag: true,
- alwaysShowCardButton: false,
+ expect(result.current).toBe(false);
});
- const { result } = renderHook(() => useIsBaanxLoginEnabled());
+ it('returns false when country is explicitly set to false in supported countries', () => {
+ mockSelectors({
+ displayCardButtonFeatureFlag: true,
+ alwaysShowCardButton: false,
+ cardGeoLocation: 'US',
+ cardSupportedCountries: { US: false },
+ });
- expect(result.current).toBe(true);
+ const { result } = renderHook(() => useIsBaanxLoginEnabled());
+
+ expect(result.current).toBe(false);
+ });
});
- it('returns true when experimental switch is enabled and feature flag is disabled', () => {
- mockSelectors({
- displayCardButtonFeatureFlag: false,
- alwaysShowCardButton: true,
+ describe('edge cases', () => {
+ it('returns false when all flags are null', () => {
+ mockSelectors({
+ displayCardButtonFeatureFlag: null,
+ alwaysShowCardButton: null,
+ cardGeoLocation: 'US',
+ cardSupportedCountries: null,
+ });
+
+ const { result } = renderHook(() => useIsBaanxLoginEnabled());
+
+ expect(result.current).toBe(false);
});
- const { result } = renderHook(() => useIsBaanxLoginEnabled());
+ it('returns false when all flags are undefined', () => {
+ mockSelectors({
+ displayCardButtonFeatureFlag: undefined,
+ alwaysShowCardButton: undefined,
+ cardGeoLocation: 'US',
+ cardSupportedCountries: undefined,
+ });
- expect(result.current).toBe(true);
- });
+ const { result } = renderHook(() => useIsBaanxLoginEnabled());
- it('returns true when both flags are enabled', () => {
- mockSelectors({
- displayCardButtonFeatureFlag: true,
- alwaysShowCardButton: true,
+ expect(result.current).toBe(false);
});
- const { result } = renderHook(() => useIsBaanxLoginEnabled());
+ it('returns false when cardSupportedCountries is empty object', () => {
+ mockSelectors({
+ displayCardButtonFeatureFlag: true,
+ alwaysShowCardButton: false,
+ cardGeoLocation: 'US',
+ cardSupportedCountries: {},
+ });
- expect(result.current).toBe(true);
+ const { result } = renderHook(() => useIsBaanxLoginEnabled());
+
+ expect(result.current).toBe(false);
+ });
+
+ it('returns false when cardGeoLocation is empty string', () => {
+ mockSelectors({
+ displayCardButtonFeatureFlag: true,
+ alwaysShowCardButton: false,
+ cardGeoLocation: '',
+ cardSupportedCountries: { US: true },
+ });
+
+ const { result } = renderHook(() => useIsBaanxLoginEnabled());
+
+ expect(result.current).toBe(false);
+ });
});
- it('returns true when experimental switch is enabled regardless of feature flag state', () => {
- mockSelectors({
- displayCardButtonFeatureFlag: false,
- alwaysShowCardButton: true,
+ describe('multiple supported countries', () => {
+ it('returns true when user is in one of multiple supported countries', () => {
+ mockSelectors({
+ displayCardButtonFeatureFlag: true,
+ alwaysShowCardButton: false,
+ cardGeoLocation: 'GB',
+ cardSupportedCountries: { US: true, GB: true, CA: true },
+ });
+
+ const { result } = renderHook(() => useIsBaanxLoginEnabled());
+
+ expect(result.current).toBe(true);
});
- const { result } = renderHook(() => useIsBaanxLoginEnabled());
+ it('returns false when user is not in any supported country', () => {
+ mockSelectors({
+ displayCardButtonFeatureFlag: true,
+ alwaysShowCardButton: false,
+ cardGeoLocation: 'DE',
+ cardSupportedCountries: { US: true, GB: true, CA: true },
+ });
- expect(result.current).toBe(true);
+ const { result } = renderHook(() => useIsBaanxLoginEnabled());
+
+ expect(result.current).toBe(false);
+ });
});
it('updates when flag values change', () => {
mockSelectors({
displayCardButtonFeatureFlag: false,
alwaysShowCardButton: false,
+ cardGeoLocation: 'US',
+ cardSupportedCountries: { US: true },
});
const { result, rerender } = renderHook(() => useIsBaanxLoginEnabled());
@@ -114,6 +211,8 @@ describe('useIsBaanxLoginEnabled', () => {
mockSelectors({
displayCardButtonFeatureFlag: false,
alwaysShowCardButton: true,
+ cardGeoLocation: 'US',
+ cardSupportedCountries: { US: true },
});
rerender();
diff --git a/app/components/UI/Card/hooks/isBaanxLoginEnabled.ts b/app/components/UI/Card/hooks/isBaanxLoginEnabled.ts
index d94a74a9dc0..8cb77a2c494 100644
--- a/app/components/UI/Card/hooks/isBaanxLoginEnabled.ts
+++ b/app/components/UI/Card/hooks/isBaanxLoginEnabled.ts
@@ -1,17 +1,41 @@
import { useSelector } from 'react-redux';
-import { selectDisplayCardButtonFeatureFlag } from '../../../../selectors/featureFlagController/card';
-import { selectAlwaysShowCardButton } from '../../../../core/redux/slices/card';
+import {
+ selectCardSupportedCountries,
+ selectDisplayCardButtonFeatureFlag,
+} from '../../../../selectors/featureFlagController/card';
+import {
+ selectAlwaysShowCardButton,
+ selectCardGeoLocation,
+} from '../../../../core/redux/slices/card';
+
+export const isBaanxLoginEnabled = (params: {
+ alwaysShowCardButton: boolean;
+ cardGeoLocation: string;
+ cardSupportedCountries: Record;
+ displayCardButtonFeatureFlag: boolean;
+}) =>
+ params.alwaysShowCardButton ||
+ (params.cardSupportedCountries?.[params.cardGeoLocation] === true &&
+ params.displayCardButtonFeatureFlag) ||
+ false;
const useIsBaanxLoginEnabled = () => {
const displayCardButtonFeatureFlag = useSelector(
selectDisplayCardButtonFeatureFlag,
);
const alwaysShowCardButton = useSelector(selectAlwaysShowCardButton);
+ const cardGeoLocation = useSelector(selectCardGeoLocation);
+ const cardSupportedCountries = useSelector(selectCardSupportedCountries);
// If user has explicitly enabled the experimental switch,
// they should have full access to the feature including authentication/onboarding,
// regardless of the progressive rollout flag state
- return alwaysShowCardButton || displayCardButtonFeatureFlag || false;
+ return isBaanxLoginEnabled({
+ alwaysShowCardButton,
+ cardGeoLocation,
+ displayCardButtonFeatureFlag,
+ cardSupportedCountries: cardSupportedCountries as Record,
+ });
};
export default useIsBaanxLoginEnabled;
diff --git a/app/components/UI/Card/hooks/useGetDelegationSettings.test.ts b/app/components/UI/Card/hooks/useGetDelegationSettings.test.ts
index 44e0e813f16..e817c49810a 100644
--- a/app/components/UI/Card/hooks/useGetDelegationSettings.test.ts
+++ b/app/components/UI/Card/hooks/useGetDelegationSettings.test.ts
@@ -136,6 +136,7 @@ describe('useGetDelegationSettings', () => {
expect.any(Function),
{
cacheDuration: 600000, // 10 minutes in milliseconds
+ fetchOnMount: true,
},
);
});
@@ -148,6 +149,7 @@ describe('useGetDelegationSettings', () => {
expect(options).toEqual({
cacheDuration: 10 * 60 * 1000,
+ fetchOnMount: true,
});
});
});
diff --git a/app/components/UI/Card/hooks/useGetDelegationSettings.ts b/app/components/UI/Card/hooks/useGetDelegationSettings.ts
index ed49a8c11d4..a07c558266e 100644
--- a/app/components/UI/Card/hooks/useGetDelegationSettings.ts
+++ b/app/components/UI/Card/hooks/useGetDelegationSettings.ts
@@ -20,11 +20,10 @@ const useGetDelegationSettings = () => {
return sdk.getDelegationSettings();
}, [sdk, isAuthenticated]);
- return useWrapWithCache(
- 'delegation-settings',
- fetchDelegationSettings,
- { cacheDuration: 10 * 60 * 1000 }, // 10 minutes cache
- );
+ return useWrapWithCache('delegation-settings', fetchDelegationSettings, {
+ cacheDuration: 10 * 60 * 1000, // 10 minutes cache
+ fetchOnMount: isAuthenticated,
+ });
};
export default useGetDelegationSettings;
diff --git a/app/components/UI/Card/util/metrics.ts b/app/components/UI/Card/util/metrics.ts
index acf7127708a..f028c48bbe8 100644
--- a/app/components/UI/Card/util/metrics.ts
+++ b/app/components/UI/Card/util/metrics.ts
@@ -55,4 +55,9 @@ enum CardActions {
NAVIGATE_TO_CARD_PAGE = 'NAVIGATE_TO_CARD_PAGE',
}
-export { CardScreens, CardActions };
+enum CardDeeplinkActions {
+ CARD_ONBOARDING = 'CARD_ONBOARDING',
+ CARD_HOME = 'CARD_HOME',
+}
+
+export { CardScreens, CardActions, CardDeeplinkActions };
diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx
index 96ad227fa4a..2c59753eaaa 100644
--- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx
+++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx
@@ -30,8 +30,10 @@ import {
PerpsTransaction,
} from '../../types/transactionHistory';
import {
+ formatPerpsFiat,
formatPositiveFiat,
formatTransactionDate,
+ PRICE_RANGES_UNIVERSAL,
} from '../../utils/formatUtils';
import { styleSheet } from './PerpsPositionTransactionView.styles';
@@ -103,7 +105,9 @@ const PerpsPositionTransactionView: React.FC = () => {
transaction.fill?.action === 'Closed'
? strings('perps.transactions.position.close_price')
: strings('perps.transactions.position.entry_price'),
- value: formatPositiveFiat(transaction.fill.entryPrice),
+ value: formatPerpsFiat(transaction.fill.entryPrice, {
+ ranges: PRICE_RANGES_UNIVERSAL,
+ }),
},
].filter(Boolean);
diff --git a/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.test.tsx b/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.test.tsx
index b9706d954b8..8ab4571fee0 100644
--- a/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.test.tsx
+++ b/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.test.tsx
@@ -107,6 +107,10 @@ jest.mock('bignumber.js', () => ({
// Mock stream hooks
jest.mock('../../hooks/stream', () => ({
usePerpsLivePrices: jest.fn(() => ({})),
+ usePerpsTopOfBook: jest.fn(() => ({
+ bestBid: '2995',
+ bestAsk: '3005',
+ })),
}));
// Mock usePerpsConnection hook
@@ -114,6 +118,32 @@ jest.mock('../../hooks/index', () => ({
usePerpsConnection: jest.fn(),
}));
+// Mock usePerpsEventTracking hook
+jest.mock('../../hooks/usePerpsEventTracking', () => ({
+ usePerpsEventTracking: jest.fn(() => ({
+ track: jest.fn(),
+ })),
+}));
+
+// Mock eventNames constants
+jest.mock('../../constants/eventNames', () => ({
+ PerpsEventProperties: {
+ INTERACTION_TYPE: 'interaction_type',
+ SETTING_TYPE: 'setting_type',
+ INPUT_METHOD: 'input_method',
+ ASSET: 'asset',
+ DIRECTION: 'direction',
+ },
+ PerpsEventValues: {
+ INTERACTION_TYPE: { SETTING_CHANGED: 'setting_changed' },
+ INPUT_METHOD: {
+ PRESET: 'preset',
+ PERCENTAGE_BUTTON: 'percentage_button',
+ KEYBOARD: 'keyboard',
+ },
+ },
+}));
+
// Mock Keypad component from Base
// Mock BottomSheet components
jest.mock(
@@ -329,9 +359,11 @@ describe('PerpsLimitPriceBottomSheet', () => {
jest.clearAllMocks();
mockUseTheme.mockReturnValue(mockTheme);
- // Mock usePerpsLivePrices hook to return empty by default
- const { usePerpsLivePrices } = jest.requireMock('../../hooks/stream');
+ // Mock stream hooks
+ const { usePerpsLivePrices, usePerpsTopOfBook } =
+ jest.requireMock('../../hooks/stream');
usePerpsLivePrices.mockReturnValue({});
+ usePerpsTopOfBook.mockReturnValue({ bestBid: '2995', bestAsk: '3005' });
// Mock usePerpsConnection hook
const { usePerpsConnection } = jest.requireMock('../../hooks/index');
@@ -451,43 +483,83 @@ describe('PerpsLimitPriceBottomSheet', () => {
});
describe('Quick Action Buttons', () => {
- it('displays direction-specific preset buttons for long orders', () => {
- // Act
+ it('displays Mid, Bid, and percentage preset buttons for long orders', () => {
render();
- // Assert - Long orders show negative percentages (buy below market)
+ expect(
+ screen.getByText('perps.order.limit_price_modal.mid_price'),
+ ).toBeOnTheScreen();
+ expect(
+ screen.getByText('perps.order.limit_price_modal.bid_price'),
+ ).toBeOnTheScreen();
expect(screen.getByText('-1%')).toBeOnTheScreen();
expect(screen.getByText('-2%')).toBeOnTheScreen();
- expect(screen.getByText('-5%')).toBeOnTheScreen();
- expect(screen.getByText('-10%')).toBeOnTheScreen();
});
- it('displays direction-specific preset buttons for short orders', () => {
- // Act
+ it('displays Mid, Ask, and percentage preset buttons for short orders', () => {
render(
,
);
- // Assert - Short orders show positive percentages (sell above market)
+ expect(
+ screen.getByText('perps.order.limit_price_modal.mid_price'),
+ ).toBeOnTheScreen();
+ expect(
+ screen.getByText('perps.order.limit_price_modal.ask_price'),
+ ).toBeOnTheScreen();
expect(screen.getByText('+1%')).toBeOnTheScreen();
expect(screen.getByText('+2%')).toBeOnTheScreen();
- expect(screen.getByText('+5%')).toBeOnTheScreen();
- expect(screen.getByText('+10%')).toBeOnTheScreen();
});
- it('calculates price based on current market price for long orders', () => {
- // Act
+ it('sets price when Mid button is pressed', () => {
+ render();
+
+ const midButton = screen.getByText(
+ 'perps.order.limit_price_modal.mid_price',
+ );
+ fireEvent.press(midButton);
+
+ // Verify limit price was updated to current price (3000)
+ expect(screen.getByTestId('keypad-value')).toHaveTextContent('3000');
+ });
+
+ it('sets price when Bid button is pressed for long orders', () => {
+ render();
+
+ const bidButton = screen.getByText(
+ 'perps.order.limit_price_modal.bid_price',
+ );
+ fireEvent.press(bidButton);
+
+ // Verify limit price was updated to bid price (2995 from mock)
+ expect(screen.getByTestId('keypad-value')).toHaveTextContent('2995');
+ });
+
+ it('sets price when Ask button is pressed for short orders', () => {
+ render(
+ ,
+ );
+
+ const askButton = screen.getByText(
+ 'perps.order.limit_price_modal.ask_price',
+ );
+ fireEvent.press(askButton);
+
+ // Verify limit price was updated to ask price (3005 from mock)
+ expect(screen.getByTestId('keypad-value')).toHaveTextContent('3005');
+ });
+
+ it('sets price when percentage button is pressed for long orders', () => {
render();
const onePercentButton = screen.getByText('-1%');
fireEvent.press(onePercentButton);
- // Assert - Button exists and is pressable
- expect(onePercentButton).toBeOnTheScreen();
+ // Verify limit price was updated (BigNumber mock returns base price)
+ expect(screen.getByTestId('keypad-value')).toHaveTextContent('3000');
});
- it('calculates price based on current market price for short orders', () => {
- // Act
+ it('sets price when percentage button is pressed for short orders', () => {
render(
,
);
@@ -495,8 +567,8 @@ describe('PerpsLimitPriceBottomSheet', () => {
const onePercentButton = screen.getByText('+1%');
fireEvent.press(onePercentButton);
- // Assert - Button exists and is pressable
- expect(onePercentButton).toBeOnTheScreen();
+ // Verify limit price was updated (BigNumber mock returns base price)
+ expect(screen.getByTestId('keypad-value')).toHaveTextContent('3000');
});
});
diff --git a/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.tsx b/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.tsx
index fb1298f5b0b..33acf906346 100644
--- a/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.tsx
+++ b/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.tsx
@@ -23,12 +23,18 @@ import {
} from '../../utils/formatUtils';
import { getPerpsDisplaySymbol } from '../../utils/marketUtils';
import { createStyles } from './PerpsLimitPriceBottomSheet.styles';
-import { usePerpsLivePrices } from '../../hooks/stream';
+import { usePerpsLivePrices, usePerpsTopOfBook } from '../../hooks/stream';
import {
PERPS_CONSTANTS,
LIMIT_PRICE_CONFIG,
} from '../../constants/perpsConfig';
import { BigNumber } from 'bignumber.js';
+import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking';
+import { MetaMetricsEvents } from '../../../../hooks/useMetrics';
+import {
+ PerpsEventProperties,
+ PerpsEventValues,
+} from '../../constants/eventNames';
interface PerpsLimitPriceBottomSheetProps {
isVisible: boolean;
@@ -46,7 +52,7 @@ interface PerpsLimitPriceBottomSheetProps {
* Modal for setting limit order prices with direction-specific presets
*
* Features:
- * - Direction-aware presets (Long: -1%, -2%, -5%, -10% | Short: +1%, +2%, +5%, +10%)
+ * - Direction-aware presets (Long: Mid, Bid, -1%, -2% | Short: Mid, Ask, +1%, +2%)
* - Custom keypad for price input
* - Real-time current market price display
* - Automatic preset calculation based on current price
@@ -72,6 +78,12 @@ const PerpsLimitPriceBottomSheet: React.FC = ({
// Initialize with initial limit price or empty to show placeholder
const [limitPrice, setLimitPrice] = useState(initialLimitPrice || '');
+ // Track input method for MetaMetrics (preset = Mid/Bid/Ask, percentage_button = %, keyboard = manual)
+ const [inputMethod, setInputMethod] = useState(null);
+
+ // MetaMetrics tracking
+ const { track } = usePerpsEventTracking();
+
// Get real-time price data with 1000ms throttle for limit price bottom sheet
// Only subscribe when visible
const priceData = usePerpsLivePrices({
@@ -85,8 +97,15 @@ const PerpsLimitPriceBottomSheet: React.FC = ({
? parseFloat(currentPriceData.price)
: passedCurrentPrice;
+ // Get top of book (bid/ask) data for Bid/Ask preset buttons
+ // Note: Mid price comes from currentPrice above (from allMids stream)
+ const topOfBook = usePerpsTopOfBook({ symbol: isVisible ? asset : '' });
+ const bidPrice = topOfBook?.bestBid;
+ const askPrice = topOfBook?.bestAsk;
+
useEffect(() => {
if (isVisible) {
+ setInputMethod(null); // Reset input method tracking for new session
bottomSheetRef.current?.onOpenBottomSheet();
// Start cursor blinking animation
@@ -114,6 +133,19 @@ const PerpsLimitPriceBottomSheet: React.FC = ({
const handleConfirm = () => {
// Remove any formatting (commas, dollar signs) before passing the value
const cleanPrice = limitPrice.replace(/[$,]/g, '');
+
+ // Track limit price input method
+ if (inputMethod) {
+ track(MetaMetricsEvents.PERPS_UI_INTERACTION, {
+ [PerpsEventProperties.INTERACTION_TYPE]:
+ PerpsEventValues.INTERACTION_TYPE.SETTING_CHANGED,
+ [PerpsEventProperties.SETTING_TYPE]: 'limit_price',
+ [PerpsEventProperties.INPUT_METHOD]: inputMethod,
+ [PerpsEventProperties.ASSET]: asset,
+ [PerpsEventProperties.DIRECTION]: direction,
+ });
+ }
+
// Only call onConfirm; parent controls visibility. Avoid calling onClose here
// to distinguish between confirm vs dismiss (onClose used for cancel/dismiss).
onConfirm(cleanPrice);
@@ -127,6 +159,7 @@ const PerpsLimitPriceBottomSheet: React.FC = ({
return; // Ignore input that would exceed 9 digits
}
setLimitPrice(value || '');
+ setInputMethod(PerpsEventValues.INPUT_METHOD.KEYBOARD);
},
[],
);
@@ -334,23 +367,69 @@ const PerpsLimitPriceBottomSheet: React.FC = ({
: PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY}
- {/* Quick percentage buttons - Direction-specific presets using config */}
+ {/* Quick preset buttons - Mid/Bid/Ask + percentage presets */}
+ {/* Mid price button - uses currentPrice which is the mid price from allMids stream */}
+ {
+ if (currentPrice) {
+ setLimitPrice(
+ formatWithSignificantDigits(currentPrice, 4).value.toString(),
+ );
+ setInputMethod(PerpsEventValues.INPUT_METHOD.PRESET);
+ }
+ }}
+ >
+
+ {strings('perps.order.limit_price_modal.mid_price')}
+
+
+
{direction === 'long' ? (
- // For long orders: buy below market using LONG_PRESETS
+ // For long orders: Mid, Bid, -1%, -2%
<>
- {LIMIT_PRICE_CONFIG.LONG_PRESETS.map((percentage) => (
-
+ {/* Bid price button */}
+ {
+ const price = bidPrice || currentPriceData?.price;
+ if (price) {
setLimitPrice(
formatWithSignificantDigits(
- parseFloat(calculatePriceForPercentage(percentage)),
+ parseFloat(price),
4,
).value.toString(),
- )
+ );
+ setInputMethod(PerpsEventValues.INPUT_METHOD.PRESET);
}
+ }}
+ >
+
+ {strings('perps.order.limit_price_modal.bid_price')}
+
+
+
+ {/* Percentage presets */}
+ {LIMIT_PRICE_CONFIG.LONG_PRESETS.map((percentage) => (
+ {
+ const calculatedPrice =
+ calculatePriceForPercentage(percentage);
+ if (calculatedPrice) {
+ setLimitPrice(
+ formatWithSignificantDigits(
+ parseFloat(calculatedPrice),
+ 4,
+ ).value.toString(),
+ );
+ setInputMethod(
+ PerpsEventValues.INPUT_METHOD.PERCENTAGE_BUTTON,
+ );
+ }
+ }}
>
{percentage > 0 ? '+' : ''}
@@ -360,15 +439,49 @@ const PerpsLimitPriceBottomSheet: React.FC = ({
))}
>
) : (
- // For short orders: sell above market using SHORT_PRESETS
+ // For short orders: Mid, Ask, +1%, +2%
<>
+ {/* Ask price button */}
+ {
+ const price = askPrice || currentPriceData?.price;
+ if (price) {
+ setLimitPrice(
+ formatWithSignificantDigits(
+ parseFloat(price),
+ 4,
+ ).value.toString(),
+ );
+ setInputMethod(PerpsEventValues.INPUT_METHOD.PRESET);
+ }
+ }}
+ >
+
+ {strings('perps.order.limit_price_modal.ask_price')}
+
+
+
+ {/* Percentage presets */}
{LIMIT_PRICE_CONFIG.SHORT_PRESETS.map((percentage) => (
- setLimitPrice(calculatePriceForPercentage(percentage))
- }
+ onPress={() => {
+ const calculatedPrice =
+ calculatePriceForPercentage(percentage);
+ if (calculatedPrice) {
+ setLimitPrice(
+ formatWithSignificantDigits(
+ parseFloat(calculatedPrice),
+ 4,
+ ).value.toString(),
+ );
+ setInputMethod(
+ PerpsEventValues.INPUT_METHOD.PERCENTAGE_BUTTON,
+ );
+ }
+ }}
>
{percentage > 0 ? '+' : ''}
diff --git a/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.test.tsx b/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.test.tsx
index f154163cb5b..4b113f1ace9 100644
--- a/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.test.tsx
+++ b/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.test.tsx
@@ -210,7 +210,7 @@ describe('PerpsMarketTradesList', () => {
render();
- expect(screen.queryByText('See all')).not.toBeOnTheScreen();
+ expect(screen.queryByTestId('see-all-button')).not.toBeOnTheScreen();
});
});
@@ -237,7 +237,7 @@ describe('PerpsMarketTradesList', () => {
expect(screen.getByText('Recent activity')).toBeOnTheScreen();
});
- it('does not render See all button when empty', () => {
+ it('renders See all button when empty', () => {
mockUsePerpsLiveFills.mockReturnValue({
fills: [],
isInitialLoading: false,
@@ -245,7 +245,7 @@ describe('PerpsMarketTradesList', () => {
render();
- expect(screen.queryByText('See all')).not.toBeOnTheScreen();
+ expect(screen.getByTestId('see-all-button')).toBeOnTheScreen();
});
});
@@ -272,7 +272,7 @@ describe('PerpsMarketTradesList', () => {
render();
expect(screen.getByText('Recent activity')).toBeOnTheScreen();
- expect(screen.getByText('See all')).toBeOnTheScreen();
+ expect(screen.getByTestId('see-all-button')).toBeOnTheScreen();
});
it('renders trade subtitles correctly', () => {
@@ -347,7 +347,7 @@ describe('PerpsMarketTradesList', () => {
render();
- const seeAllButton = screen.getByText('See all');
+ const seeAllButton = screen.getByTestId('see-all-button');
fireEvent.press(seeAllButton);
expect(mockNavigate).toHaveBeenCalledTimes(1);
diff --git a/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.tsx b/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.tsx
index 1e0eba09cd3..730ddc955dd 100644
--- a/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.tsx
+++ b/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.tsx
@@ -137,8 +137,8 @@ const PerpsMarketTradesList: React.FC = ({
{strings('perps.market.recent_trades')}
- {!isLoading && trades.length > 0 && (
-
+ {!isLoading && (
+
{strings('perps.home.see_all')}
diff --git a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.styles.ts b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.styles.ts
index c52e9a587f5..cc863692224 100644
--- a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.styles.ts
+++ b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.styles.ts
@@ -89,10 +89,10 @@ const createStyles = (params: {
flexDirection: 'column',
alignItems: 'stretch',
gap: 16,
+ marginBottom: 16,
},
skipButton: {
paddingHorizontal: 16,
- marginBottom: 16,
alignSelf: 'center',
opacity: params.vars.shouldShowSkipButton ? 1 : 0,
},
diff --git a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.test.tsx b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.test.tsx
index 236fa44a1bc..4b3ff1f5e29 100644
--- a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.test.tsx
+++ b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.test.tsx
@@ -262,7 +262,7 @@ describe('PerpsTutorialCarousel', () => {
expect(mockMarkTutorialCompleted).toHaveBeenCalled();
});
- it('enables skip button on last screen for eligible users', async () => {
+ it("shows Learn more and Let's go buttons on last screen", async () => {
render();
// Navigate to the last screen
@@ -273,9 +273,14 @@ describe('PerpsTutorialCarousel', () => {
screen.getByText(strings('perps.tutorial.ready_to_trade.title')),
).toBeOnTheScreen();
- // Skip button should be enabled on last screen for eligible users
- const skipButton = screen.getByTestId('perps-tutorial-skip-button');
- expect(skipButton.props.disabled).toBe(false);
+ // Skip button is hidden on last screen
+ expect(screen.queryByTestId('perps-tutorial-skip-button')).toBeNull();
+
+ // Learn more button should be visible on last screen
+ const learnMoreButton = screen.getByTestId(
+ 'perps-tutorial-learn-more-button',
+ );
+ expect(learnMoreButton).toBeOnTheScreen();
// Main "Let's go" button should be visible
const continueButton = screen.getByTestId(
@@ -375,7 +380,7 @@ describe('PerpsTutorialCarousel', () => {
}
});
- it('shows "Got it" button on last screen for eligible users', async () => {
+ it('shows "Let\'s go" and "Learn more" buttons on last screen for eligible users', async () => {
render();
// Navigate through all screens to get to last screen
@@ -392,9 +397,14 @@ describe('PerpsTutorialCarousel', () => {
);
expect(continueButton).toBeOnTheScreen();
- // Skip button should be enabled for eligible users on last screen
- const skipButton = screen.getByTestId('perps-tutorial-skip-button');
- expect(skipButton.props.disabled).toBe(false);
+ // Learn more button should be visible on last screen
+ const learnMoreButton = screen.getByTestId(
+ 'perps-tutorial-learn-more-button',
+ );
+ expect(learnMoreButton).toBeOnTheScreen();
+
+ // Skip button is hidden on last screen
+ expect(screen.queryByTestId('perps-tutorial-skip-button')).toBeNull();
});
it('navigates to perps home when eligible user completes tutorial', async () => {
@@ -509,7 +519,7 @@ describe('PerpsTutorialCarousel', () => {
).not.toBeOnTheScreen();
});
- it('shows "Let\'s go" button and disables skip button on last screen for non-eligible users', async () => {
+ it('shows "Let\'s go" button and hides skip button on last screen for non-eligible users', async () => {
render();
// Navigate through all screens to get to last screen (4 clicks for 5 screens)
@@ -526,9 +536,8 @@ describe('PerpsTutorialCarousel', () => {
);
expect(continueButton).toBeOnTheScreen();
- // Skip button should be disabled on last screen
- const skipButton = screen.getByTestId('perps-tutorial-skip-button');
- expect(skipButton.props.disabled).toBe(true);
+ // Skip button is hidden on last screen (for both eligible and non-eligible users)
+ expect(screen.queryByTestId('perps-tutorial-skip-button')).toBeNull();
});
it('shows skip button for non-eligible users on non-last screens', () => {
diff --git a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx
index bc2e86c756e..b4ba78cfa2b 100644
--- a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx
+++ b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx
@@ -26,6 +26,7 @@ import Text, {
import { useStyles } from '../../../../../component-library/hooks';
import Routes from '../../../../../constants/navigation/Routes';
import NavigationService from '../../../../../core/NavigationService';
+import { EXTERNAL_LINK_TYPE } from '../../../../../constants/browser';
import { MetaMetricsEvents } from '../../../../hooks/useMetrics';
import {
PerpsEventProperties,
@@ -118,7 +119,6 @@ const getTutorialScreens = (isEligible: boolean): TutorialScreen[] => {
id: 'ready_to_trade',
title: strings('perps.tutorial.ready_to_trade.title'),
description: strings('perps.tutorial.ready_to_trade.description'),
- footerText: strings('perps.tutorial.ready_to_trade.footer_text'),
riveArtboardName: PERPS_RIVE_ARTBOARD_NAMES.READY,
};
@@ -357,6 +357,18 @@ const PerpsTutorialCarousel: React.FC = () => {
navigateToMarketsList,
]);
+ const handleLearnMore = useCallback(() => {
+ NavigationService.navigation.navigate(Routes.BROWSER.HOME, {
+ screen: Routes.BROWSER.VIEW,
+ params: {
+ newTabUrl: 'https://support.metamask.io/manage-crypto/trade/perps',
+ linkType: EXTERNAL_LINK_TYPE,
+ timestamp: Date.now(),
+ fromPerps: true,
+ },
+ });
+ }, []);
+
const renderTabBar = () => ;
const buttonLabel = useMemo(() => {
@@ -464,6 +476,16 @@ const PerpsTutorialCarousel: React.FC = () => {
{/* Footer */}
+ {isLastScreen && (
+
+ )}
+
+ )}
diff --git a/app/components/UI/Perps/components/TradingViewChart/TradingViewChartTemplate.tsx b/app/components/UI/Perps/components/TradingViewChart/TradingViewChartTemplate.tsx
index 4c60593b1f5..3f8a71807bb 100644
--- a/app/components/UI/Perps/components/TradingViewChart/TradingViewChartTemplate.tsx
+++ b/app/components/UI/Perps/components/TradingViewChart/TradingViewChartTemplate.tsx
@@ -217,41 +217,41 @@ export const createTradingViewChartTemplate = (
timeZone: userTimezone
});
case 'DayOfMonth':
- // Always show day + month for DayOfMonth tick type (e.g., 1D candles)
- // Format: "17 Nov" (day before month)
- const day = date.toLocaleString('en-US', {
- day: 'numeric',
+ // Always show month/day for DayOfMonth tick type (e.g., 1D candles)
+ // Format: "11/6" (MM/DD numeric format)
+ const dayOfMonthMonth = date.toLocaleString('en-US', {
+ month: 'numeric',
timeZone: userTimezone
});
- const month = date.toLocaleString('en-US', {
- month: 'short',
+ const dayOfMonthDay = date.toLocaleString('en-US', {
+ day: 'numeric',
timeZone: userTimezone
});
- return day + ' ' + month;
+ return dayOfMonthMonth + '/' + dayOfMonthDay;
case 'Hour':
case 'Minute':
// Show date + time if not today, otherwise just time
if (!window.isToday(date, userTimezone)) {
- // Format: "17 Nov 00:15"
- const day = date.toLocaleString('en-US', {
- day: 'numeric',
+ // Format: "11/17 00:15" (MM/DD + time)
+ const hourMinuteMonth = date.toLocaleString('en-US', {
+ month: 'numeric',
timeZone: userTimezone
});
- const month = date.toLocaleString('en-US', {
- month: 'short',
+ const hourMinuteDay = date.toLocaleString('en-US', {
+ day: 'numeric',
timeZone: userTimezone
});
- const timeStr = date.toLocaleString('en-US', {
- hour: '2-digit',
+ const timeStr = date.toLocaleString('en-US', {
+ hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: userTimezone
});
- return day + ' ' + month + ' ' + timeStr;
+ return hourMinuteMonth + '/' + hourMinuteDay + ' ' + timeStr;
} else {
// Show time only for today
- return date.toLocaleString('en-US', {
- hour: '2-digit',
+ return date.toLocaleString('en-US', {
+ hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: userTimezone
@@ -281,26 +281,26 @@ export const createTradingViewChartTemplate = (
if (timeSpanHours <= 24) {
// Less than 24 hours: show date + time if not today, otherwise just time
if (!window.isToday(date, userTimezone)) {
- // Format: "17 Nov 00:15"
- const day = date.toLocaleString('en-US', {
- day: 'numeric',
+ // Format: "11/17 00:15" (MM/DD + time)
+ const fb24Month = date.toLocaleString('en-US', {
+ month: 'numeric',
timeZone: userTimezone
});
- const month = date.toLocaleString('en-US', {
- month: 'short',
+ const fb24Day = date.toLocaleString('en-US', {
+ day: 'numeric',
timeZone: userTimezone
});
- const timeStr = date.toLocaleString('en-US', {
- hour: '2-digit',
+ const fb24TimeStr = date.toLocaleString('en-US', {
+ hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: userTimezone
});
- return day + ' ' + month + ' ' + timeStr;
+ return fb24Month + '/' + fb24Day + ' ' + fb24TimeStr;
} else {
// Show time only for today
- return date.toLocaleString('en-US', {
- hour: '2-digit',
+ return date.toLocaleString('en-US', {
+ hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: userTimezone
@@ -309,65 +309,65 @@ export const createTradingViewChartTemplate = (
} else if (timeSpanHours <= 24 * 7) {
// Less than a week: show date + time if not today, otherwise just time
if (!window.isToday(date, userTimezone)) {
- // Format: "17 Nov 00:15"
- const day = date.toLocaleString('en-US', {
- day: 'numeric',
+ // Format: "11/17 00:15" (MM/DD + time)
+ const fbWeekMonth = date.toLocaleString('en-US', {
+ month: 'numeric',
timeZone: userTimezone
});
- const month = date.toLocaleString('en-US', {
- month: 'short',
+ const fbWeekDay = date.toLocaleString('en-US', {
+ day: 'numeric',
timeZone: userTimezone
});
- const timeStr = date.toLocaleString('en-US', {
- hour: '2-digit',
+ const fbWeekTimeStr = date.toLocaleString('en-US', {
+ hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: userTimezone
});
- return day + ' ' + month + ' ' + timeStr;
+ return fbWeekMonth + '/' + fbWeekDay + ' ' + fbWeekTimeStr;
} else {
// Show time only for today
- return date.toLocaleString('en-US', {
- hour: '2-digit',
+ return date.toLocaleString('en-US', {
+ hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: userTimezone
});
}
} else {
- // Longer ranges: always show day + month (e.g., "17 Nov")
+ // Longer ranges: always show month/day (e.g., "11/6")
// This is especially important for 1D candles
- const day = date.toLocaleString('en-US', {
- day: 'numeric',
+ const fbLongMonth = date.toLocaleString('en-US', {
+ month: 'numeric',
timeZone: userTimezone
});
- const month = date.toLocaleString('en-US', {
- month: 'short',
+ const fbLongDay = date.toLocaleString('en-US', {
+ day: 'numeric',
timeZone: userTimezone
});
- return day + ' ' + month;
+ return fbLongMonth + '/' + fbLongDay;
}
}
}
// Final fallback: show date and time
- // Format: "17 Nov 00:15"
- const day = date.toLocaleString('en-US', {
- day: 'numeric',
+ // Format: "11/17 00:15" (MM/DD + time)
+ const finalMonth = date.toLocaleString('en-US', {
+ month: 'numeric',
timeZone: userTimezone
});
- const month = date.toLocaleString('en-US', {
- month: 'short',
+ const finalDay = date.toLocaleString('en-US', {
+ day: 'numeric',
timeZone: userTimezone
});
- const timeStr = date.toLocaleString('en-US', {
- hour: '2-digit',
+ const finalTimeStr = date.toLocaleString('en-US', {
+ hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: userTimezone
});
- return day + ' ' + month + ' ' + timeStr;
+ return finalMonth + '/' + finalDay + ' ' + finalTimeStr;
}
};
diff --git a/app/components/UI/Perps/constants/perpsConfig.ts b/app/components/UI/Perps/constants/perpsConfig.ts
index f2d07802303..fd85daed253 100644
--- a/app/components/UI/Perps/constants/perpsConfig.ts
+++ b/app/components/UI/Perps/constants/perpsConfig.ts
@@ -219,15 +219,15 @@ export const TP_SL_VIEW_CONFIG = {
*/
export const LIMIT_PRICE_CONFIG = {
// Preset percentage options for quick selection
- PRESET_PERCENTAGES: [1, 2, 5, 10], // Available as both positive and negative
+ PRESET_PERCENTAGES: [1, 2], // Available as both positive and negative
// Modal opening delay when switching to limit order (milliseconds)
// Allows order type modal to close smoothly before opening limit price modal
MODAL_OPEN_DELAY: 300,
- // Direction-specific preset configurations
- LONG_PRESETS: [-1, -2, -5, -10], // Buy below market for long orders
- SHORT_PRESETS: [1, 2, 5, 10], // Sell above market for short orders
+ // Direction-specific preset configurations (Mid/Bid/Ask buttons handled separately)
+ LONG_PRESETS: [-1, -2], // Buy below market for long orders
+ SHORT_PRESETS: [1, 2], // Sell above market for short orders
} as const;
/**
diff --git a/app/components/Views/Browser/index.js b/app/components/Views/Browser/index.js
index fc271a1c16a..0d68eea3247 100644
--- a/app/components/Views/Browser/index.js
+++ b/app/components/Views/Browser/index.js
@@ -413,6 +413,7 @@ export const Browser = (props) => {
isInTabsView={shouldShowTabs}
homePageUrl={homePageUrl()}
fromTrending={route.params?.fromTrending}
+ fromPerps={route.params?.fromPerps}
/>
) : (
{
showTabsView,
activeTabId,
route.params?.fromTrending,
+ route.params?.fromPerps,
],
);
diff --git a/app/components/Views/BrowserTab/BrowserTab.tsx b/app/components/Views/BrowserTab/BrowserTab.tsx
index 56f2a72fc5f..ad01b513374 100644
--- a/app/components/Views/BrowserTab/BrowserTab.tsx
+++ b/app/components/Views/BrowserTab/BrowserTab.tsx
@@ -149,6 +149,7 @@ export const BrowserTab: React.FC = React.memo(
homePageUrl,
activeChainId,
fromTrending,
+ fromPerps,
}) => {
// This any can be removed when react navigation is bumped to v6 - issue https://github.com/react-navigation/react-navigation/issues/9037#issuecomment-735698288
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -1313,7 +1314,12 @@ export const BrowserTab: React.FC = React.memo(
);
const handleBackPress = useCallback(() => {
- if (fromTrending) {
+ if (fromPerps) {
+ // If opened from Perps, navigate back to PerpsHome
+ navigation.navigate(Routes.PERPS.ROOT, {
+ screen: Routes.PERPS.PERPS_HOME,
+ });
+ } else if (fromTrending) {
// If within trending follow the normal back button behavior
navigation.goBack();
} else {
@@ -1322,7 +1328,7 @@ export const BrowserTab: React.FC = React.memo(
screen: Routes.TRENDING_FEED,
});
}
- }, [navigation, fromTrending]);
+ }, [navigation, fromTrending, fromPerps]);
const onCancelUrlBar = useCallback(() => {
hideAutocomplete();
diff --git a/app/components/Views/BrowserTab/types.ts b/app/components/Views/BrowserTab/types.ts
index 44139b843c3..a8a15cb7e2d 100644
--- a/app/components/Views/BrowserTab/types.ts
+++ b/app/components/Views/BrowserTab/types.ts
@@ -110,6 +110,10 @@ export type BrowserTabProps = SharedTabProps & {
* Whether browser was opened from trending view
*/
fromTrending?: boolean;
+ /**
+ * Whether browser was opened from Perps view
+ */
+ fromPerps?: boolean;
/**
* Boolean indicating if browser is in fullscreen mode
diff --git a/app/components/Views/Quiz/QuizContent/styles.ts b/app/components/Views/Quiz/QuizContent/styles.ts
index c5b64777993..48dacb4025a 100644
--- a/app/components/Views/Quiz/QuizContent/styles.ts
+++ b/app/components/Views/Quiz/QuizContent/styles.ts
@@ -42,8 +42,8 @@ const styleSheet = (params: { theme: Theme }) => {
width: '100%',
},
image: {
- width: 300,
- height: 250,
+ width: 190,
+ height: 220,
},
bottomContainer: {
width: '100%',
diff --git a/app/components/Views/Quiz/SRPQuiz/SRPQuiz.test.tsx b/app/components/Views/Quiz/SRPQuiz/SRPQuiz.test.tsx
index 1e63130ecb7..c6e2b7830be 100644
--- a/app/components/Views/Quiz/SRPQuiz/SRPQuiz.test.tsx
+++ b/app/components/Views/Quiz/SRPQuiz/SRPQuiz.test.tsx
@@ -2,6 +2,7 @@ import React from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react-native';
import configureMockStore from 'redux-mock-store';
import { Provider } from 'react-redux';
+import { SafeAreaProvider, Metrics } from 'react-native-safe-area-context';
import { ThemeContext, mockTheme } from '../../../../util/theme';
import {
SrpQuizGetStartedSelectorsIDs,
@@ -15,6 +16,11 @@ import { Linking } from 'react-native';
const mockNavigate = jest.fn();
+const initialMetrics: Metrics = {
+ frame: { x: 0, y: 0, width: 390, height: 844 },
+ insets: { top: 47, left: 0, right: 0, bottom: 34 },
+};
+
jest.mock('@react-navigation/native', () => ({
useNavigation: () => ({
navigate: mockNavigate,
@@ -43,11 +49,13 @@ const renderSRPQuiz = (
const store = mockStore(initialState);
const renderResult = render(
-
-
-
-
- ,
+
+
+
+
+
+
+ ,
);
if (completeQuiz) {
diff --git a/app/components/Views/Quiz/SRPQuiz/SRPQuiz.tsx b/app/components/Views/Quiz/SRPQuiz/SRPQuiz.tsx
index 47e73d33799..bf61db21ba7 100644
--- a/app/components/Views/Quiz/SRPQuiz/SRPQuiz.tsx
+++ b/app/components/Views/Quiz/SRPQuiz/SRPQuiz.tsx
@@ -2,7 +2,9 @@
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { View, Linking, AppState } from 'react-native';
import { useNavigation } from '@react-navigation/native';
-import ReusableModal, { ReusableModalRef } from '../../../UI/ReusableModal';
+import BottomSheet, {
+ BottomSheetRef,
+} from '../../../../component-library/components/BottomSheets/BottomSheet';
import { ButtonVariants } from '../../../../component-library/components/Buttons/Button';
import Icon, {
IconSize,
@@ -30,7 +32,7 @@ import {
import { selectSeedlessOnboardingLoginFlow } from '../../../../selectors/seedlessOnboardingController';
import { useSelector } from 'react-redux';
-const introductionImg = require('../../../../images/reveal-srp.png');
+const introductionImg = require('../../../../images/reveal_srp.png');
export interface SRPQuizProps {
route: {
@@ -48,7 +50,7 @@ const SRPQuiz = (props: SRPQuizProps) => {
params: { keyringId },
},
} = props;
- const modalRef = useRef(null);
+ const modalRef = useRef(null);
const [stage, setStage] = useState(QuizStage.introduction);
const { styles, theme } = useStyles(stylesheet, {});
const { colors } = theme;
@@ -56,7 +58,7 @@ const SRPQuiz = (props: SRPQuizProps) => {
const { trackEvent, createEventBuilder } = useMetrics();
const dismissModal = (): void => {
- modalRef.current?.dismissModal();
+ modalRef.current?.onCloseBottomSheet();
};
useEffect(() => {
@@ -428,9 +430,9 @@ const SRPQuiz = (props: SRPQuizProps) => {
]);
return (
-
+
{quizPage()}
-
+
);
};
diff --git a/app/components/Views/RevealPrivateCredential/RevealPrivateCredential.test.tsx b/app/components/Views/RevealPrivateCredential/RevealPrivateCredential.test.tsx
index 031f2a5fa61..c581c9fc9a0 100644
--- a/app/components/Views/RevealPrivateCredential/RevealPrivateCredential.test.tsx
+++ b/app/components/Views/RevealPrivateCredential/RevealPrivateCredential.test.tsx
@@ -253,9 +253,8 @@ describe('RevealPrivateCredential', () => {
/>,
);
- // Should render SRP explanation instead of AccountInfo
+ // Renders SRP explanation instead of AccountInfo
expect(getByText('Secret Recovery Phrase')).toBeTruthy();
- expect(getByText('non-custodial wallet.')).toBeTruthy();
});
it('shows warning message on incorrect password', async () => {
diff --git a/app/components/Views/RevealPrivateCredential/RevealPrivateCredential.tsx b/app/components/Views/RevealPrivateCredential/RevealPrivateCredential.tsx
index b0c6035369d..886daf368ef 100644
--- a/app/components/Views/RevealPrivateCredential/RevealPrivateCredential.tsx
+++ b/app/components/Views/RevealPrivateCredential/RevealPrivateCredential.tsx
@@ -32,11 +32,7 @@ import { ScreenshotDeterrent } from '../../UI/ScreenshotDeterrent';
import { showAlert } from '../../../actions/alert';
import { recordSRPRevealTimestamp } from '../../../actions/privacy';
import { WRONG_PASSWORD_ERROR } from '../../../constants/error';
-import {
- KEEP_SRP_SAFE_URL,
- NON_CUSTODIAL_WALLET_URL,
- SRP_GUIDE_URL,
-} from '../../../constants/urls';
+import { KEEP_SRP_SAFE_URL, SRP_GUIDE_URL } from '../../../constants/urls';
import ClipboardManager from '../../../core/ClipboardManager';
import { useTheme } from '../../../util/theme';
import { MetaMetricsEvents } from '../../../core/Analytics';
@@ -408,9 +404,6 @@ const RevealPrivateCredential = ({
tabLabel={strings(`reveal_credential.text`)}
testID={RevealSeedViewSelectorsIDs.TAB_SCROLL_VIEW_TEXT}
>
-
- {strings(`reveal_credential.${privCredentialName}`)}
-
{' '}
{strings('reveal_credential.seed_phrase_explanation')[2]}{' '}
{strings('reveal_credential.seed_phrase_explanation')[3]}
- {strings('reveal_credential.seed_phrase_explanation')[4]}{' '}
- Linking.openURL(NON_CUSTODIAL_WALLET_URL)}
- >
- {strings('reveal_credential.seed_phrase_explanation')[5]}{' '}
-
- {strings('reveal_credential.seed_phrase_explanation')[6]}{' '}
- {strings('reveal_credential.seed_phrase_explanation')[7]}
);
diff --git a/app/components/Views/RevealPrivateCredential/__snapshots__/RevealPrivateCredential.test.tsx.snap b/app/components/Views/RevealPrivateCredential/__snapshots__/RevealPrivateCredential.test.tsx.snap
index a6eb4b03c22..e34d082d5b6 100644
--- a/app/components/Views/RevealPrivateCredential/__snapshots__/RevealPrivateCredential.test.tsx.snap
+++ b/app/components/Views/RevealPrivateCredential/__snapshots__/RevealPrivateCredential.test.tsx.snap
@@ -143,42 +143,6 @@ exports[`RevealPrivateCredential handles keyring ID parameter correctly 1`] = `
}
>
full access to your wallet, funds and accounts.
-
-
-
- MetaMask is a
-
-
- non-custodial wallet.
-
-
- That means,
-
-
- you are the owner of your Secret Recovery Phrase.
full access to your wallet, funds and accounts.
-
-
-
- MetaMask is a
-
-
- non-custodial wallet.
-
-
- That means,
-
-
- you are the owner of your Secret Recovery Phrase.
full access to your wallet, funds and accounts.
-
-
-
- MetaMask is a
-
-
- non-custodial wallet.
-
-
- That means,
-
-
- you are the owner of your Secret Recovery Phrase.
{isOTAUpdatesEnabled && (
<>
-
- {`Expo Project ID: ${PROJECT_ID}`}
-
{`Update ID: ${updateId || 'N/A'}`}
@@ -257,9 +253,6 @@ class AppInformation extends PureComponent {
{`OTA Update runtime version: ${runtimeVersion}`}
-
- {`OTA Update URL: ${otaUpdateUrl}`}
-
{`Check Automatically: ${checkAutomatically}`}
diff --git a/app/components/Views/Settings/AppInformation/index.test.tsx b/app/components/Views/Settings/AppInformation/index.test.tsx
index 87efdae95fd..f6d32832d3b 100644
--- a/app/components/Views/Settings/AppInformation/index.test.tsx
+++ b/app/components/Views/Settings/AppInformation/index.test.tsx
@@ -36,6 +36,16 @@ jest.mock(
}),
);
+jest.mock('../../../../constants/ota', () => {
+ const actual = jest.requireActual('../../../../constants/ota');
+
+ return {
+ ...actual,
+ // Make getFullVersion a pass-through so tests don't depend on OTA_VERSION
+ getFullVersion: (appVersion: string) => appVersion,
+ };
+});
+
const MOCK_STATE = {
engine: {
backgroundState: {
@@ -446,11 +456,9 @@ describe('AppInformation', () => {
{ state: MOCK_STATE },
);
- expect(queryByText(/Expo Project ID:/)).toBeNull();
expect(queryByText(/Update ID:/)).toBeNull();
expect(queryByText(/OTA Update Channel:/)).toBeNull();
expect(queryByText(/OTA Update runtime version:/)).toBeNull();
- expect(queryByText(/OTA Update URL:/)).toBeNull();
expect(queryByText(/Check Automatically:/)).toBeNull();
expect(queryByText(/OTA Update status:/)).toBeNull();
});
@@ -475,7 +483,6 @@ describe('AppInformation', () => {
expect(getByText(/Update ID: mock-update-id/)).toBeTruthy();
expect(getByText(/OTA Update Channel: test-channel/)).toBeTruthy();
expect(getByText(/OTA Update runtime version: 1.0.0/)).toBeTruthy();
- expect(getByText(/OTA Update URL: https:\/\/example.com/)).toBeTruthy();
expect(getByText(/Check Automatically: NEVER/)).toBeTruthy();
expect(getByText(/OTA Update status:/)).toBeTruthy();
});
diff --git a/app/constants/deeplinks.ts b/app/constants/deeplinks.ts
index d1cc02049b8..832fe721b6e 100644
--- a/app/constants/deeplinks.ts
+++ b/app/constants/deeplinks.ts
@@ -15,6 +15,7 @@ export enum PROTOCOLS {
export enum ACTIONS {
RAMP = 'ramp',
ENABLE_CARD_BUTTON = 'enable-card-button',
+ CARD_ONBOARDING = 'card-onboarding',
DAPP = 'dapp',
SEND = 'send',
APPROVE = 'approve',
@@ -67,5 +68,6 @@ export const PREFIXES = {
[ACTIONS.PREDICT]: '',
[ACTIONS.ONBOARDING]: '',
[ACTIONS.ENABLE_CARD_BUTTON]: '',
+ [ACTIONS.CARD_ONBOARDING]: '',
METAMASK: 'metamask://',
};
diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts
index ed79ebf5b30..dc0361e7bb7 100644
--- a/app/constants/navigation/Routes.ts
+++ b/app/constants/navigation/Routes.ts
@@ -380,6 +380,7 @@ const Routes = {
///: END:ONLY_INCLUDE_IF
CARD: {
ROOT: 'CardScreens',
+ CARD_MAIN_ROUTES: 'CardMainRoutes',
HOME: 'CardHome',
WELCOME: 'CardWelcome',
AUTHENTICATION: 'CardAuthentication',
diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts
index af05a01482e..4ab707e1bfa 100644
--- a/app/core/Analytics/MetaMetrics.events.ts
+++ b/app/core/Analytics/MetaMetrics.events.ts
@@ -555,6 +555,7 @@ enum EVENT_NAME {
CARD_DELEGATION_PROCESS_COMPLETED = 'Card Delegation Process Completed',
CARD_DELEGATION_PROCESS_FAILED = 'Card Delegation Process Failed',
CARD_DELEGATION_PROCESS_USER_CANCELED = 'Card Delegation Process User Canceled',
+ CARD_ONBOARDING_DEEPLINK = 'Card Onboarding Deeplink',
// Rewards
REWARDS_ACCOUNT_LINKING_STARTED = 'Rewards Account Linking Started',
REWARDS_ACCOUNT_LINKING_COMPLETED = 'Rewards Account Linking Completed',
@@ -1467,6 +1468,7 @@ const events = {
CARD_DELEGATION_PROCESS_USER_CANCELED: generateOpt(
EVENT_NAME.CARD_DELEGATION_PROCESS_USER_CANCELED,
),
+ CARD_ONBOARDING_DEEPLINK: generateOpt(EVENT_NAME.CARD_ONBOARDING_DEEPLINK),
// Rewards
REWARDS_ACCOUNT_LINKING_STARTED: generateOpt(
EVENT_NAME.REWARDS_ACCOUNT_LINKING_STARTED,
diff --git a/app/core/BackgroundBridge/BackgroundBridge.test.js b/app/core/BackgroundBridge/BackgroundBridge.test.js
index 05330ff0bce..e9bf96948af 100644
--- a/app/core/BackgroundBridge/BackgroundBridge.test.js
+++ b/app/core/BackgroundBridge/BackgroundBridge.test.js
@@ -1,4 +1,4 @@
-import getDefaultBridgeParams from '../SDKConnect/AndroidSDK/getDefaultBridgeParams';
+import getDefaultBridgeParams from '../SDKConnect/getDefaultBridgeParams';
import BackgroundBridge from './BackgroundBridge';
import Engine from '../Engine';
import { getPermittedAccounts } from '../Permissions';
diff --git a/app/core/DeeplinkManager/handlers/legacy/__tests__/handleCardOnboarding.test.ts b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleCardOnboarding.test.ts
new file mode 100644
index 00000000000..3c899e36f0c
--- /dev/null
+++ b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleCardOnboarding.test.ts
@@ -0,0 +1,629 @@
+import { handleCardOnboarding } from '../handleCardOnboarding';
+import ReduxService from '../../../../redux';
+import NavigationService from '../../../../NavigationService';
+import Engine from '../../../../Engine';
+import Routes from '../../../../../constants/navigation/Routes';
+import DevLogger from '../../../../SDKConnect/utils/DevLogger';
+import Logger from '../../../../../util/Logger';
+import {
+ selectCardholderAccounts,
+ selectIsAuthenticatedCard,
+ selectCardGeoLocation,
+ setAlwaysShowCardButton,
+} from '../../../../redux/slices/card';
+import {
+ selectCardExperimentalSwitch,
+ selectCardSupportedCountries,
+ selectDisplayCardButtonFeatureFlag,
+} from '../../../../../selectors/featureFlagController/card';
+
+jest.mock('../../../../redux', () => ({
+ __esModule: true,
+ default: {
+ store: {
+ getState: jest.fn(),
+ dispatch: jest.fn(),
+ },
+ },
+}));
+jest.mock('../../../../NavigationService');
+jest.mock('../../../../Engine', () => ({
+ setSelectedAddress: jest.fn(),
+}));
+jest.mock('../../../../redux/slices/card');
+jest.mock('../../../../../selectors/featureFlagController/card');
+jest.mock('../../../../SDKConnect/utils/DevLogger');
+jest.mock('../../../../../util/Logger');
+jest.mock('../../../../Analytics', () => {
+ const actualMockTrackEvent = jest.fn();
+ return {
+ MetaMetrics: {
+ getInstance: jest.fn(() => ({
+ trackEvent: actualMockTrackEvent,
+ })),
+ __mockTrackEvent: actualMockTrackEvent,
+ },
+ MetaMetricsEvents: {
+ CARD_ONBOARDING_DEEPLINK: 'Card Onboarding Deeplink',
+ },
+ };
+});
+jest.mock('../../../../Analytics/MetricsEventBuilder', () => {
+ const mockBuilder = {
+ addProperties: jest.fn(),
+ build: jest.fn().mockReturnValue({ event: 'mocked_event' }),
+ };
+ mockBuilder.addProperties.mockReturnValue(mockBuilder);
+ return {
+ MetricsEventBuilder: {
+ createEventBuilder: jest.fn(() => mockBuilder),
+ },
+ };
+});
+
+describe('handleCardOnboarding', () => {
+ const mockGetState = jest.fn();
+ const mockDispatch = jest.fn();
+ const mockNavigate = jest.fn();
+ const mockDevLogger = DevLogger.log as jest.Mock;
+ const mockLoggerError = Logger.error as jest.Mock;
+
+ const mockCardholderAddress = '0x1234567890abcdef1234567890abcdef12345678';
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ jest.clearAllMocks();
+
+ (ReduxService.store.getState as jest.Mock) = mockGetState;
+ (ReduxService.store.dispatch as jest.Mock) = mockDispatch;
+ mockGetState.mockReturnValue({});
+
+ NavigationService.navigation = {
+ navigate: mockNavigate,
+ } as unknown as typeof NavigationService.navigation;
+
+ // Default mocks - onboarding disabled
+ (selectCardholderAccounts as unknown as jest.Mock).mockReturnValue([]);
+ (selectIsAuthenticatedCard as unknown as jest.Mock).mockReturnValue(false);
+ (selectCardGeoLocation as unknown as jest.Mock).mockReturnValue('US');
+ (selectCardExperimentalSwitch as unknown as jest.Mock).mockReturnValue(
+ false,
+ );
+ (
+ selectDisplayCardButtonFeatureFlag as unknown as jest.Mock
+ ).mockReturnValue(false);
+ (selectCardSupportedCountries as unknown as jest.Mock).mockReturnValue({});
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ jest.clearAllMocks();
+ });
+
+ describe('feature flag edge cases', () => {
+ describe('when cardExperimentalSwitch is enabled', () => {
+ beforeEach(() => {
+ (selectCardExperimentalSwitch as unknown as jest.Mock).mockReturnValue(
+ true,
+ );
+ });
+
+ it('enables onboarding and navigates to Card Welcome for unauthenticated user', () => {
+ handleCardOnboarding();
+
+ expect(mockDispatch).toHaveBeenCalledWith(
+ setAlwaysShowCardButton(true),
+ );
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, {
+ screen: Routes.CARD.WELCOME,
+ });
+ });
+
+ it('enables onboarding regardless of geo location', () => {
+ (selectCardGeoLocation as unknown as jest.Mock).mockReturnValue(
+ 'UNSUPPORTED_COUNTRY',
+ );
+
+ handleCardOnboarding();
+
+ expect(mockDispatch).toHaveBeenCalledWith(
+ setAlwaysShowCardButton(true),
+ );
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, {
+ screen: Routes.CARD.WELCOME,
+ });
+ });
+
+ it('enables onboarding regardless of displayCardButtonFeatureFlag', () => {
+ (
+ selectDisplayCardButtonFeatureFlag as unknown as jest.Mock
+ ).mockReturnValue(false);
+
+ handleCardOnboarding();
+
+ expect(mockDispatch).toHaveBeenCalledWith(
+ setAlwaysShowCardButton(true),
+ );
+ expect(mockNavigate).toHaveBeenCalled();
+ });
+ });
+
+ describe('when displayCardButtonFeatureFlag is enabled with supported country', () => {
+ beforeEach(() => {
+ (selectCardExperimentalSwitch as unknown as jest.Mock).mockReturnValue(
+ false,
+ );
+ (
+ selectDisplayCardButtonFeatureFlag as unknown as jest.Mock
+ ).mockReturnValue(true);
+ (selectCardGeoLocation as unknown as jest.Mock).mockReturnValue('GB');
+ (selectCardSupportedCountries as unknown as jest.Mock).mockReturnValue({
+ GB: true,
+ DE: true,
+ FR: true,
+ });
+ });
+
+ it('enables onboarding and dispatches setAlwaysShowCardButton', () => {
+ handleCardOnboarding();
+
+ expect(mockDispatch).toHaveBeenCalledWith(
+ setAlwaysShowCardButton(true),
+ );
+ expect(mockNavigate).toHaveBeenCalled();
+ });
+
+ it('navigates to Card Welcome for unauthenticated user without card account', () => {
+ handleCardOnboarding();
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, {
+ screen: Routes.CARD.WELCOME,
+ });
+ });
+ });
+
+ describe('when displayCardButtonFeatureFlag is enabled but country is not supported', () => {
+ beforeEach(() => {
+ (selectCardExperimentalSwitch as unknown as jest.Mock).mockReturnValue(
+ false,
+ );
+ (
+ selectDisplayCardButtonFeatureFlag as unknown as jest.Mock
+ ).mockReturnValue(true);
+ (selectCardGeoLocation as unknown as jest.Mock).mockReturnValue(
+ 'UNSUPPORTED',
+ );
+ (selectCardSupportedCountries as unknown as jest.Mock).mockReturnValue({
+ GB: true,
+ DE: true,
+ });
+ });
+
+ it('does not dispatch setAlwaysShowCardButton', () => {
+ handleCardOnboarding();
+
+ expect(mockDispatch).not.toHaveBeenCalled();
+ });
+
+ it('does not navigate', () => {
+ handleCardOnboarding();
+
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ it('logs that onboarding is not enabled', () => {
+ handleCardOnboarding();
+
+ expect(mockDevLogger).toHaveBeenCalledWith(
+ '[handleCardOnboarding] Card onboarding is not enabled, skipping',
+ );
+ });
+ });
+
+ describe('when displayCardButtonFeatureFlag is disabled but country is in supported list', () => {
+ beforeEach(() => {
+ (selectCardExperimentalSwitch as unknown as jest.Mock).mockReturnValue(
+ false,
+ );
+ (
+ selectDisplayCardButtonFeatureFlag as unknown as jest.Mock
+ ).mockReturnValue(false);
+ (selectCardGeoLocation as unknown as jest.Mock).mockReturnValue('GB');
+ (selectCardSupportedCountries as unknown as jest.Mock).mockReturnValue({
+ GB: true,
+ });
+ });
+
+ it('does not enable onboarding', () => {
+ handleCardOnboarding();
+
+ expect(mockDispatch).not.toHaveBeenCalled();
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when country is explicitly set to false in supported countries', () => {
+ beforeEach(() => {
+ (selectCardExperimentalSwitch as unknown as jest.Mock).mockReturnValue(
+ false,
+ );
+ (
+ selectDisplayCardButtonFeatureFlag as unknown as jest.Mock
+ ).mockReturnValue(true);
+ (selectCardGeoLocation as unknown as jest.Mock).mockReturnValue('XX');
+ (selectCardSupportedCountries as unknown as jest.Mock).mockReturnValue({
+ XX: false,
+ GB: true,
+ });
+ });
+
+ it('does not enable onboarding', () => {
+ handleCardOnboarding();
+
+ expect(mockDispatch).not.toHaveBeenCalled();
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when both feature flags are disabled', () => {
+ beforeEach(() => {
+ (selectCardExperimentalSwitch as unknown as jest.Mock).mockReturnValue(
+ false,
+ );
+ (
+ selectDisplayCardButtonFeatureFlag as unknown as jest.Mock
+ ).mockReturnValue(false);
+ });
+
+ it('does not enable onboarding', () => {
+ handleCardOnboarding();
+
+ expect(mockDispatch).not.toHaveBeenCalled();
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ it('logs skipping message', () => {
+ handleCardOnboarding();
+
+ expect(mockDevLogger).toHaveBeenCalledWith(
+ '[handleCardOnboarding] Card onboarding is not enabled, skipping',
+ );
+ });
+ });
+
+ describe('when cardSupportedCountries is undefined or null', () => {
+ beforeEach(() => {
+ (selectCardExperimentalSwitch as unknown as jest.Mock).mockReturnValue(
+ false,
+ );
+ (
+ selectDisplayCardButtonFeatureFlag as unknown as jest.Mock
+ ).mockReturnValue(true);
+ (selectCardGeoLocation as unknown as jest.Mock).mockReturnValue('GB');
+ });
+
+ it('does not enable onboarding when cardSupportedCountries is undefined', () => {
+ (selectCardSupportedCountries as unknown as jest.Mock).mockReturnValue(
+ undefined,
+ );
+
+ handleCardOnboarding();
+
+ expect(mockDispatch).not.toHaveBeenCalled();
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ it('does not enable onboarding when cardSupportedCountries is null', () => {
+ (selectCardSupportedCountries as unknown as jest.Mock).mockReturnValue(
+ null,
+ );
+
+ handleCardOnboarding();
+
+ expect(mockDispatch).not.toHaveBeenCalled();
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('navigation behavior when onboarding is enabled', () => {
+ beforeEach(() => {
+ // Enable onboarding via experimental switch
+ (selectCardExperimentalSwitch as unknown as jest.Mock).mockReturnValue(
+ true,
+ );
+ });
+
+ describe('when user is authenticated and has card-linked account', () => {
+ beforeEach(() => {
+ (selectIsAuthenticatedCard as unknown as jest.Mock).mockReturnValue(
+ true,
+ );
+ (selectCardholderAccounts as unknown as jest.Mock).mockReturnValue([
+ mockCardholderAddress,
+ ]);
+ });
+
+ it('navigates to Card Home with showDeeplinkToast param', () => {
+ handleCardOnboarding();
+ jest.runAllTimers();
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, {
+ screen: Routes.CARD.HOME,
+ params: {
+ screen: Routes.CARD.HOME,
+ params: {
+ showDeeplinkToast: true,
+ },
+ },
+ });
+ });
+
+ it('switches to first cardholder account', () => {
+ handleCardOnboarding();
+
+ expect(Engine.setSelectedAddress).toHaveBeenCalledWith(
+ mockCardholderAddress,
+ );
+ });
+
+ it('logs the account switch', () => {
+ handleCardOnboarding();
+
+ expect(mockDevLogger).toHaveBeenCalledWith(
+ '[handleCardOnboarding] Switching to first cardholder account:',
+ mockCardholderAddress,
+ );
+ });
+ });
+
+ describe('when user is authenticated but has no card-linked account', () => {
+ beforeEach(() => {
+ (selectIsAuthenticatedCard as unknown as jest.Mock).mockReturnValue(
+ true,
+ );
+ (selectCardholderAccounts as unknown as jest.Mock).mockReturnValue([]);
+ });
+
+ it('navigates to Card Home with showDeeplinkToast param', () => {
+ handleCardOnboarding();
+ jest.runAllTimers();
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, {
+ screen: Routes.CARD.HOME,
+ params: {
+ screen: Routes.CARD.HOME,
+ params: {
+ showDeeplinkToast: true,
+ },
+ },
+ });
+ });
+
+ it('does not switch account', () => {
+ handleCardOnboarding();
+
+ expect(Engine.setSelectedAddress).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when user is not authenticated but has card-linked account', () => {
+ beforeEach(() => {
+ (selectIsAuthenticatedCard as unknown as jest.Mock).mockReturnValue(
+ false,
+ );
+ (selectCardholderAccounts as unknown as jest.Mock).mockReturnValue([
+ mockCardholderAddress,
+ ]);
+ });
+
+ it('navigates to Card Home with showDeeplinkToast param', () => {
+ handleCardOnboarding();
+ jest.runAllTimers();
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, {
+ screen: Routes.CARD.HOME,
+ params: {
+ screen: Routes.CARD.HOME,
+ params: {
+ showDeeplinkToast: true,
+ },
+ },
+ });
+ });
+
+ it('switches to first cardholder account', () => {
+ handleCardOnboarding();
+
+ expect(Engine.setSelectedAddress).toHaveBeenCalledWith(
+ mockCardholderAddress,
+ );
+ });
+ });
+
+ describe('when user is not authenticated and has no card-linked account', () => {
+ beforeEach(() => {
+ (selectIsAuthenticatedCard as unknown as jest.Mock).mockReturnValue(
+ false,
+ );
+ (selectCardholderAccounts as unknown as jest.Mock).mockReturnValue([]);
+ });
+
+ it('navigates to Card Welcome', () => {
+ handleCardOnboarding();
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, {
+ screen: Routes.CARD.WELCOME,
+ });
+ });
+
+ it('does not switch account', () => {
+ handleCardOnboarding();
+
+ expect(Engine.setSelectedAddress).not.toHaveBeenCalled();
+ });
+
+ it('logs navigation to Card Welcome', () => {
+ handleCardOnboarding();
+
+ expect(mockDevLogger).toHaveBeenCalledWith(
+ '[handleCardOnboarding] Navigating to Card Welcome (onboarding)',
+ );
+ });
+ });
+
+ describe('with multiple cardholder accounts', () => {
+ const secondCardholderAddress =
+ '0xabcdef1234567890abcdef1234567890abcdef12';
+
+ beforeEach(() => {
+ (selectCardholderAccounts as unknown as jest.Mock).mockReturnValue([
+ mockCardholderAddress,
+ secondCardholderAddress,
+ ]);
+ });
+
+ it('switches to first cardholder account only', () => {
+ handleCardOnboarding();
+
+ expect(Engine.setSelectedAddress).toHaveBeenCalledWith(
+ mockCardholderAddress,
+ );
+ expect(Engine.setSelectedAddress).not.toHaveBeenCalledWith(
+ secondCardholderAddress,
+ );
+ });
+ });
+ });
+
+ describe('error handling', () => {
+ beforeEach(() => {
+ (selectCardExperimentalSwitch as unknown as jest.Mock).mockReturnValue(
+ true,
+ );
+ });
+
+ describe('when getState throws an error', () => {
+ const mockError = new Error('Redux state error');
+
+ beforeEach(() => {
+ mockGetState.mockImplementation(() => {
+ throw mockError;
+ });
+ });
+
+ it('logs error with DevLogger', () => {
+ handleCardOnboarding();
+
+ expect(mockDevLogger).toHaveBeenCalledWith(
+ '[handleCardOnboarding] Failed to handle deeplink:',
+ mockError,
+ );
+ });
+
+ it('logs error with Logger', () => {
+ handleCardOnboarding();
+
+ expect(mockLoggerError).toHaveBeenCalledWith(
+ mockError,
+ '[handleCardOnboarding] Error handling card onboarding deeplink',
+ );
+ });
+
+ it('falls back to Card Welcome navigation', () => {
+ handleCardOnboarding();
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, {
+ screen: Routes.CARD.WELCOME,
+ });
+ });
+ });
+
+ describe('when account switch fails', () => {
+ const switchError = new Error('Account switch failed');
+
+ beforeEach(() => {
+ (selectCardholderAccounts as unknown as jest.Mock).mockReturnValue([
+ mockCardholderAddress,
+ ]);
+ (Engine.setSelectedAddress as jest.Mock).mockImplementation(() => {
+ throw switchError;
+ });
+ });
+
+ it('logs the error but continues', () => {
+ handleCardOnboarding();
+
+ expect(mockDevLogger).toHaveBeenCalledWith(
+ '[handleCardOnboarding] Error switching account:',
+ switchError,
+ );
+ expect(mockLoggerError).toHaveBeenCalledWith(
+ switchError,
+ '[handleCardOnboarding] Failed to switch to cardholder account',
+ );
+ });
+
+ it('still navigates to Card Home with showDeeplinkToast param', () => {
+ handleCardOnboarding();
+ jest.runAllTimers();
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, {
+ screen: Routes.CARD.HOME,
+ params: {
+ screen: Routes.CARD.HOME,
+ params: {
+ showDeeplinkToast: true,
+ },
+ },
+ });
+ });
+ });
+
+ describe('when fallback navigation fails', () => {
+ const mainError = new Error('Main error');
+
+ beforeEach(() => {
+ mockGetState.mockImplementation(() => {
+ throw mainError;
+ });
+ mockNavigate.mockImplementation(() => {
+ throw new Error('Navigation error');
+ });
+ });
+
+ it('logs the navigation error', () => {
+ handleCardOnboarding();
+
+ expect(mockLoggerError).toHaveBeenCalledWith(
+ expect.objectContaining({ message: 'Navigation error' }),
+ '[handleCardOnboarding] Failed to navigate to fallback screen',
+ );
+ });
+ });
+ });
+
+ describe('logging', () => {
+ beforeEach(() => {
+ (selectCardExperimentalSwitch as unknown as jest.Mock).mockReturnValue(
+ true,
+ );
+ });
+
+ it('logs starting message', () => {
+ handleCardOnboarding();
+
+ expect(mockDevLogger).toHaveBeenCalledWith(
+ '[handleCardOnboarding] Starting card onboarding deeplink handling',
+ );
+ });
+
+ it('logs successful card button enablement', () => {
+ handleCardOnboarding();
+
+ expect(mockDevLogger).toHaveBeenCalledWith(
+ '[handleCardOnboarding] Successfully enabled card button',
+ );
+ });
+ });
+});
diff --git a/app/core/DeeplinkManager/handlers/legacy/__tests__/handleMetaMaskDeeplink.test.ts b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleMetaMaskDeeplink.test.ts
index 6cabf78ff67..0ebf3e6d4b0 100644
--- a/app/core/DeeplinkManager/handlers/legacy/__tests__/handleMetaMaskDeeplink.test.ts
+++ b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleMetaMaskDeeplink.test.ts
@@ -104,24 +104,6 @@ describe('handleMetaMaskProtocol', () => {
expect(handled).toHaveBeenCalled();
});
- describe('when url starts with ${PREFIXES.METAMASK}${ACTIONS.ANDROID_SDK}', () => {
- beforeEach(() => {
- url = `${PREFIXES.METAMASK}${ACTIONS.ANDROID_SDK}`;
- });
-
- it('calls bindAndroidSDK', () => {
- handleMetaMaskDeeplink({
- handled,
- params,
- url,
- origin,
- wcURL,
- });
-
- expect(mockBindAndroidSDK).toHaveBeenCalled();
- });
- });
-
describe('when params.comm is "deeplinking"', () => {
beforeEach(() => {
url = `${PREFIXES.METAMASK}${ACTIONS.CONNECT}`;
diff --git a/app/core/DeeplinkManager/handlers/legacy/__tests__/handleUniversalLink.test.ts b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleUniversalLink.test.ts
index 6b15a0e3cb7..391d0d1064e 100644
--- a/app/core/DeeplinkManager/handlers/legacy/__tests__/handleUniversalLink.test.ts
+++ b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleUniversalLink.test.ts
@@ -112,7 +112,6 @@ describe('handleUniversalLink', () => {
describe('SDK Actions', () => {
const testCases = [
- { action: ACTIONS.ANDROID_SDK },
{ action: ACTIONS.CONNECT },
{ action: ACTIONS.MMSDK },
] as const;
diff --git a/app/core/DeeplinkManager/handlers/legacy/handleCardOnboarding.ts b/app/core/DeeplinkManager/handlers/legacy/handleCardOnboarding.ts
new file mode 100644
index 00000000000..149c013497f
--- /dev/null
+++ b/app/core/DeeplinkManager/handlers/legacy/handleCardOnboarding.ts
@@ -0,0 +1,218 @@
+import DevLogger from '../../../SDKConnect/utils/DevLogger';
+import Logger from '../../../../util/Logger';
+import ReduxService from '../../../redux';
+import NavigationService from '../../../NavigationService';
+import Routes from '../../../../constants/navigation/Routes';
+import Engine from '../../../Engine';
+import {
+ selectCardholderAccounts,
+ selectIsAuthenticatedCard,
+ selectCardGeoLocation,
+ setAlwaysShowCardButton,
+} from '../../../redux/slices/card';
+import { MetaMetrics, MetaMetricsEvents } from '../../../Analytics';
+import { MetricsEventBuilder } from '../../../Analytics/MetricsEventBuilder';
+import {
+ selectCardExperimentalSwitch,
+ selectCardSupportedCountries,
+ selectDisplayCardButtonFeatureFlag,
+} from '../../../../selectors/featureFlagController/card';
+import { CardDeeplinkActions } from '../../../../components/UI/Card/util/metrics';
+
+/**
+ * Destination screens for card onboarding deeplink
+ */
+enum CardDeeplinkDestination {
+ CARD_HOME = 'CARD_HOME',
+ CARD_WELCOME = 'CARD_WELCOME',
+}
+
+/**
+ * Card onboarding deeplink handler
+ *
+ * This handler navigates users to the appropriate Card entry point based on their
+ * authentication state and whether they have a card-linked account.
+ *
+ * Behavior:
+ * - User is logged in or has a card-linked account: Switch to first card-linked account,
+ * navigate to Card Home, and show a toast notification
+ * - User is not logged in and has no card-linked account: Remain in current account and
+ * navigate to Card Welcome/onboarding screen
+ *
+ * Supported URL formats:
+ * - https://link.metamask.io/card-onboarding
+ * - https://metamask.app.link/card-onboarding
+ */
+export const handleCardOnboarding = () => {
+ DevLogger.log(
+ '[handleCardOnboarding] Starting card onboarding deeplink handling',
+ );
+
+ let isAuthenticated = false;
+ let hasCardLinkedAccount = false;
+ let destination: CardDeeplinkDestination =
+ CardDeeplinkDestination.CARD_WELCOME;
+
+ try {
+ const state = ReduxService.store.getState();
+ const cardholderAccounts = selectCardholderAccounts(state);
+ isAuthenticated = selectIsAuthenticatedCard(state);
+ hasCardLinkedAccount = cardholderAccounts.length > 0;
+ const cardGeoLocation = selectCardGeoLocation(state);
+ const isCardExperimentalSwitchEnabled = selectCardExperimentalSwitch(state);
+ const displayCardButtonFeatureFlag =
+ selectDisplayCardButtonFeatureFlag(state);
+ const cardSupportedCountries = selectCardSupportedCountries(
+ state,
+ ) as Record;
+ const shouldOnboardingBeEnabled =
+ isCardExperimentalSwitchEnabled ||
+ (cardSupportedCountries?.[cardGeoLocation as string] === true &&
+ displayCardButtonFeatureFlag);
+
+ if (!shouldOnboardingBeEnabled) {
+ DevLogger.log(
+ '[handleCardOnboarding] Card onboarding is not enabled, skipping',
+ );
+ return;
+ }
+
+ ReduxService.store.dispatch(setAlwaysShowCardButton(true));
+ DevLogger.log('[handleCardOnboarding] Successfully enabled card button');
+
+ // If user is logged in OR has a card-linked account
+ if (isAuthenticated || hasCardLinkedAccount) {
+ if (hasCardLinkedAccount) {
+ // Switch to the first account that has a linked card
+ const firstCardholderAddress = cardholderAccounts[0];
+ DevLogger.log(
+ '[handleCardOnboarding] Switching to first cardholder account:',
+ firstCardholderAddress,
+ );
+
+ try {
+ Engine.setSelectedAddress(firstCardholderAddress);
+ DevLogger.log(
+ '[handleCardOnboarding] Successfully switched to cardholder account',
+ );
+ } catch (switchError) {
+ // Log error but continue with navigation to Card Home
+ DevLogger.log(
+ '[handleCardOnboarding] Error switching account:',
+ switchError,
+ );
+ Logger.error(
+ switchError as Error,
+ '[handleCardOnboarding] Failed to switch to cardholder account',
+ );
+ }
+ }
+
+ destination = CardDeeplinkDestination.CARD_HOME;
+ DevLogger.log('[handleCardOnboarding] Navigating to Card Home');
+ setTimeout(() => {
+ NavigationService.navigation?.navigate(Routes.CARD.ROOT, {
+ screen: Routes.CARD.HOME,
+ params: {
+ screen: Routes.CARD.HOME,
+ params: {
+ showDeeplinkToast: true,
+ },
+ },
+ });
+ }, 500);
+ } else {
+ // User is not logged in AND has no card-linked account
+ // Navigate to Card Welcome/onboarding screen
+ destination = CardDeeplinkDestination.CARD_WELCOME;
+ DevLogger.log(
+ '[handleCardOnboarding] Navigating to Card Welcome (onboarding)',
+ );
+ NavigationService.navigation?.navigate(Routes.CARD.ROOT, {
+ screen: Routes.CARD.WELCOME,
+ });
+ }
+
+ // Track analytics event
+ trackCardOnboardingDeeplinkEvent({
+ isAuthenticated,
+ hasCardLinkedAccount,
+ destination,
+ });
+
+ Logger.log(
+ `[handleCardOnboarding] Card onboarding deeplink handled successfully. Destination: ${destination}`,
+ );
+ } catch (error) {
+ DevLogger.log('[handleCardOnboarding] Failed to handle deeplink:', error);
+ Logger.error(
+ error as Error,
+ '[handleCardOnboarding] Error handling card onboarding deeplink',
+ );
+
+ // Fallback: Navigate to Card Welcome screen
+ destination = CardDeeplinkDestination.CARD_WELCOME;
+ try {
+ NavigationService.navigation?.navigate(Routes.CARD.ROOT, {
+ screen: Routes.CARD.WELCOME,
+ });
+
+ // Track error event with fallback destination
+ trackCardOnboardingDeeplinkEvent({
+ isAuthenticated,
+ hasCardLinkedAccount,
+ destination,
+ error: true,
+ });
+ } catch (navError) {
+ Logger.error(
+ navError as Error,
+ '[handleCardOnboarding] Failed to navigate to fallback screen',
+ );
+ }
+ }
+};
+
+/**
+ * Track the card onboarding deeplink analytics event
+ */
+function trackCardOnboardingDeeplinkEvent({
+ isAuthenticated,
+ hasCardLinkedAccount,
+ destination,
+ error = false,
+}: {
+ isAuthenticated: boolean;
+ hasCardLinkedAccount: boolean;
+ destination: CardDeeplinkDestination;
+ error?: boolean;
+}) {
+ try {
+ const metrics = MetaMetrics.getInstance();
+ const event = MetricsEventBuilder.createEventBuilder(
+ MetaMetricsEvents.CARD_ONBOARDING_DEEPLINK,
+ )
+ .addProperties({
+ deeplink_type: CardDeeplinkActions.CARD_ONBOARDING,
+ authenticated: isAuthenticated,
+ has_card_linked_account: hasCardLinkedAccount,
+ final_destination: destination,
+ ...(error && { error: true }),
+ })
+ .build();
+
+ metrics.trackEvent(event);
+ DevLogger.log('[handleCardOnboarding] Analytics event tracked:', {
+ deeplink_type: CardDeeplinkActions.CARD_ONBOARDING,
+ authenticated: isAuthenticated,
+ has_card_linked_account: hasCardLinkedAccount,
+ final_destination: destination,
+ });
+ } catch (analyticsError) {
+ // Don't fail the deeplink handling if analytics fails
+ DevLogger.log(
+ '[handleCardOnboarding] Failed to track analytics:',
+ analyticsError,
+ );
+ }
+}
diff --git a/app/core/DeeplinkManager/handlers/legacy/handleMetaMaskDeeplink.ts b/app/core/DeeplinkManager/handlers/legacy/handleMetaMaskDeeplink.ts
index 39e337c0ce4..f28aa3d3bf7 100644
--- a/app/core/DeeplinkManager/handlers/legacy/handleMetaMaskDeeplink.ts
+++ b/app/core/DeeplinkManager/handlers/legacy/handleMetaMaskDeeplink.ts
@@ -28,18 +28,6 @@ export function handleMetaMaskDeeplink({
}) {
handled();
- if (url.startsWith(`${PREFIXES.METAMASK}${ACTIONS.ANDROID_SDK}`)) {
- DevLogger.log(
- `DeeplinkManager:: metamask launched via android sdk deeplink`,
- );
- SDKConnect.getInstance()
- .bindAndroidSDK()
- .catch((err) => {
- Logger.error(err, 'DeepLinkManager failed to connect');
- });
- return;
- }
-
if (url.startsWith(`${PREFIXES.METAMASK}${ACTIONS.CONNECT}`)) {
if (params.redirect && origin === AppConstants.DEEPLINKS.ORIGIN_DEEPLINK) {
SDKConnect.getInstance().state.navigation?.navigate(
diff --git a/app/core/DeeplinkManager/handlers/legacy/handleUniversalLink.ts b/app/core/DeeplinkManager/handlers/legacy/handleUniversalLink.ts
index bf570c9c76a..61cbf653d4d 100644
--- a/app/core/DeeplinkManager/handlers/legacy/handleUniversalLink.ts
+++ b/app/core/DeeplinkManager/handlers/legacy/handleUniversalLink.ts
@@ -25,6 +25,7 @@ import { handleRewardsUrl } from './handleRewardsUrl';
import { handlePredictUrl } from './handlePredictUrl';
import handleFastOnboarding from './handleFastOnboarding';
import { handleEnableCardButton } from './handleEnableCardButton';
+import { handleCardOnboarding } from './handleCardOnboarding';
import { RampType } from '../../../../reducers/fiatOrders/types';
const {
@@ -52,6 +53,7 @@ enum SUPPORTED_ACTIONS {
WC = ACTIONS.WC,
ONBOARDING = ACTIONS.ONBOARDING,
ENABLE_CARD_BUTTON = ACTIONS.ENABLE_CARD_BUTTON,
+ CARD_ONBOARDING = ACTIONS.CARD_ONBOARDING,
// MetaMask SDK specific actions
ANDROID_SDK = ACTIONS.ANDROID_SDK,
CONNECT = ACTIONS.CONNECT,
@@ -64,6 +66,7 @@ enum SUPPORTED_ACTIONS {
const WHITELISTED_ACTIONS: SUPPORTED_ACTIONS[] = [
SUPPORTED_ACTIONS.WC,
SUPPORTED_ACTIONS.ENABLE_CARD_BUTTON,
+ SUPPORTED_ACTIONS.CARD_ONBOARDING,
];
/**
@@ -341,6 +344,10 @@ async function handleUniversalLink({
handleEnableCardButton();
break;
}
+ case SUPPORTED_ACTIONS.CARD_ONBOARDING: {
+ handleCardOnboarding();
+ break;
+ }
}
}
diff --git a/app/core/SDKConnect/AndroidSDK/AndroidNativeSDKEventHandler.ts b/app/core/SDKConnect/AndroidSDK/AndroidNativeSDKEventHandler.ts
deleted file mode 100644
index 36cb256631a..00000000000
--- a/app/core/SDKConnect/AndroidSDK/AndroidNativeSDKEventHandler.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { NativeEventEmitter, NativeModules } from 'react-native';
-
-import { EventType } from '@metamask/sdk-communication-layer';
-
-export default class AndroidSDKEventHandler extends NativeEventEmitter {
- constructor() {
- super(NativeModules.RCTDeviceEventEmitter);
- }
-
- onMessageReceived(callback: (message: string) => void) {
- return this.addListener(EventType.MESSAGE, (message) => {
- callback(message);
- });
- }
-
- onClientsConnected(callback: (clientInfo: string) => void) {
- return this.addListener(EventType.CLIENTS_CONNECTED, (clientInfo) => {
- callback(clientInfo);
- });
- }
-
- onClientsDisconnected(callback: (id: string) => void) {
- return this.addListener(EventType.CLIENTS_DISCONNECTED, (id) => {
- callback(id);
- });
- }
-}
diff --git a/app/core/SDKConnect/AndroidSDK/AndroidService.ts b/app/core/SDKConnect/AndroidSDK/AndroidService.ts
deleted file mode 100644
index 4bea7fa1f61..00000000000
--- a/app/core/SDKConnect/AndroidSDK/AndroidService.ts
+++ /dev/null
@@ -1,502 +0,0 @@
-import { NetworkController } from '@metamask/network-controller';
-import { EventEmitter2 } from 'eventemitter2';
-import { NativeModules } from 'react-native';
-import Engine from '../../Engine';
-import { RPCQueueManager } from '../RPCQueueManager';
-
-import {
- EventType,
- MessageType,
- OriginatorInfo,
-} from '@metamask/sdk-communication-layer';
-import Logger from '../../../util/Logger';
-import AppConstants from '../../AppConstants';
-
-import {
- wait,
- waitForAndroidServiceBinding,
- waitForKeychainUnlocked,
-} from '../utils/wait.util';
-
-import BackgroundBridge from '../../BackgroundBridge/BackgroundBridge';
-import { SDKConnect } from '../SDKConnect';
-
-import { KeyringController } from '@metamask/keyring-controller';
-
-import { PermissionController } from '@metamask/permission-controller';
-import { PROTOCOLS } from '../../../constants/deeplinks';
-import BatchRPCManager from '../BatchRPCManager';
-import { DEFAULT_SESSION_TIMEOUT_MS } from '../SDKConnectConstants';
-import handleCustomRpcCalls from '../handlers/handleCustomRpcCalls';
-import DevLogger from '../utils/DevLogger';
-import AndroidSDKEventHandler from './AndroidNativeSDKEventHandler';
-import sendMessage from './AndroidService/sendMessage';
-import { DappClient, DappConnections } from './dapp-sdk-types';
-import getDefaultBridgeParams from './getDefaultBridgeParams';
-import { AccountsController } from '@metamask/accounts-controller';
-import { toChecksumHexAddress } from '@metamask/controller-utils';
-import Routes from '../../../constants/navigation/Routes';
-import {
- Caip25CaveatType,
- Caip25EndowmentPermissionName,
-} from '@metamask/chain-agnostic-permission';
-import { getDefaultCaip25CaveatValue } from '../../Permissions';
-
-export default class AndroidService extends EventEmitter2 {
- public communicationClient = NativeModules.CommunicationClient;
- public connections: DappConnections = {};
- public rpcQueueManager = new RPCQueueManager();
- public bridgeByClientId: { [clientId: string]: BackgroundBridge } = {};
- public eventHandler: AndroidSDKEventHandler;
- public batchRPCManager: BatchRPCManager = new BatchRPCManager('android');
- // To keep track in order to get the associated bridge to handle batch rpc calls
- public currentClientId?: string;
-
- constructor() {
- super();
-
- this.eventHandler = new AndroidSDKEventHandler();
- this.setupEventListeners()
- .then(() => {
- DevLogger.log(
- `AndroidService::constructor event listeners setup completed`,
- );
- //
- })
- .catch((err) => {
- Logger.log(err, `AndroidService:: error setting up event listeners`);
- });
- }
-
- private async setupEventListeners(): Promise {
- try {
- // Wait for keychain to be unlocked before handling rpc calls.
- const keyringController = (
- Engine.context as { KeyringController: KeyringController }
- ).KeyringController;
- await waitForKeychainUnlocked({
- keyringController,
- context: 'AndroidService::setupEventListener',
- });
-
- DevLogger.log(`AndroidService::setupEventListeners loading connections`);
- const rawConnections =
- await SDKConnect.getInstance().loadDappConnections();
-
- if (rawConnections) {
- Object.values(rawConnections).forEach((connection) => {
- DevLogger.log(
- `AndroidService::setupEventListeners recover client: ${connection.id}`,
- );
- this.connections[connection.id] = {
- connected: false,
- clientId: connection.id,
- originatorInfo: connection.originatorInfo as OriginatorInfo,
- validUntil: connection.validUntil,
- };
- });
- } else {
- DevLogger.log(
- `AndroidService::setupEventListeners no previous connections found`,
- );
- }
- } catch (err) {
- console.error(`AndroidService::setupEventListeners error`, err);
- }
-
- this.restorePreviousConnections();
-
- this.setupOnClientsConnectedListener();
- this.setupOnMessageReceivedListener();
-
- // Bind native module to client
- await SDKConnect.getInstance().bindAndroidSDK();
- }
-
- public getConnections() {
- DevLogger.log(
- `AndroidService::getConnections`,
- JSON.stringify(this.connections, null, 2),
- );
- return Object.values(this.connections).filter(
- (connection) => connection?.clientId?.length > 0,
- );
- }
-
- private setupOnClientsConnectedListener() {
- this.eventHandler.onClientsConnected(async (sClientInfo: string) => {
- const clientInfo: DappClient = JSON.parse(sClientInfo);
-
- DevLogger.log(`AndroidService::clients_connected`, clientInfo);
- if (this.connections?.[clientInfo.clientId]) {
- // Skip existing client -- bridge has been setup
- Logger.log(
- `AndroidService::clients_connected - existing client, sending ready`,
- );
-
- // Update connected state
- this.connections[clientInfo.clientId] = {
- ...this.connections[clientInfo.clientId],
- connected: true,
- };
-
- this.sendMessage(
- {
- type: MessageType.READY,
- data: {
- id: clientInfo?.clientId,
- },
- },
- false,
- ).catch((err) => {
- Logger.log(
- `AndroidService::clients_connected - error sending ready message to client ${clientInfo.clientId}`,
- err,
- );
- });
- return;
- }
-
- await SDKConnect.getInstance().addDappConnection({
- id: clientInfo.clientId,
- lastAuthorized: Date.now(),
- origin: AppConstants.MM_SDK.ANDROID_SDK,
- originatorInfo: clientInfo.originatorInfo,
- otherPublicKey: '',
- validUntil: Date.now() + DEFAULT_SESSION_TIMEOUT_MS,
- });
-
- const handleEventAsync = async () => {
- const keyringController = (
- Engine.context as { KeyringController: KeyringController }
- ).KeyringController;
-
- await waitForKeychainUnlocked({
- keyringController,
- context: 'AndroidService::setupOnClientsConnectedListener',
- });
-
- try {
- if (!this.connections?.[clientInfo.clientId]) {
- DevLogger.log(
- `AndroidService::clients_connected - new client ${clientInfo.clientId}}`,
- this.connections,
- );
- // Ask for account permissions
- await this.checkPermission({
- originatorInfo: clientInfo.originatorInfo,
- channelId: clientInfo.clientId,
- });
-
- this.setupBridge(clientInfo);
- // Save session to SDKConnect
- // Save to local connections
- this.connections[clientInfo.clientId] = {
- connected: true,
- clientId: clientInfo.clientId,
- originatorInfo: clientInfo.originatorInfo,
- validUntil: clientInfo.validUntil,
- };
- await SDKConnect.getInstance().addDappConnection({
- id: clientInfo.clientId,
- lastAuthorized: Date.now(),
- origin: AppConstants.MM_SDK.ANDROID_SDK,
- originatorInfo: clientInfo.originatorInfo,
- otherPublicKey: '',
- validUntil: Date.now() + DEFAULT_SESSION_TIMEOUT_MS,
- });
- }
-
- this.sendMessage(
- {
- type: MessageType.READY,
- data: {
- id: clientInfo?.clientId,
- },
- },
- false,
- ).catch((err) => {
- Logger.log(
- err,
- `AndroidService::clients_connected error sending READY message to client`,
- );
- });
- } catch (error) {
- Logger.log(
- error,
- `AndroidService::clients_connected sending jsonrpc error to client - connection rejected`,
- );
- this.sendMessage({
- data: {
- error,
- jsonrpc: '2.0',
- },
- name: 'metamask-provider',
- }).catch((err) => {
- Logger.log(
- err,
- `AndroidService::clients_connected error failed sending jsonrpc error to client`,
- );
- });
- SDKConnect.getInstance().state.navigation?.navigate(
- Routes.MODAL.ROOT_MODAL_FLOW,
- {
- screen: Routes.SDK.RETURN_TO_DAPP_NOTIFICATION,
- },
- );
- return;
- }
-
- this.emit(EventType.CLIENTS_CONNECTED);
- };
-
- handleEventAsync().catch((err) => {
- Logger.log(
- err,
- `AndroidService::clients_connected error handling event`,
- );
- });
- });
- }
-
- private async checkPermission({
- channelId,
- }: {
- originatorInfo: OriginatorInfo;
- channelId: string;
- }): Promise {
- const permissionsController = (
- Engine.context as {
- // TODO: Replace "any" with type
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- PermissionController: PermissionController;
- }
- ).PermissionController;
-
- return permissionsController.requestPermissions(
- { origin: channelId },
- {
- [Caip25EndowmentPermissionName]: {
- caveats: [
- {
- type: Caip25CaveatType,
- value: getDefaultCaip25CaveatValue(),
- },
- ],
- },
- },
- );
- }
-
- private setupOnMessageReceivedListener() {
- this.eventHandler.onMessageReceived((jsonMessage: string) => {
- const handleEventAsync = async () => {
- let parsedMsg: {
- id: string;
- message: string;
- };
-
- DevLogger.log(`AndroidService::onMessageReceived`, jsonMessage);
- try {
- await wait(200); // Extra wait to make sure ui is ready
-
- await waitForAndroidServiceBinding();
- const keyringController = (
- Engine.context as { KeyringController: KeyringController }
- ).KeyringController;
- await waitForKeychainUnlocked({
- keyringController,
- context: 'AndroidService::setupOnMessageReceivedListener',
- });
- } catch (error) {
- Logger.log(error, `AndroidService::onMessageReceived error`);
- }
-
- let sessionId: string,
- message: string,
- // TODO: Replace "any" with type
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- data: { id: string; jsonrpc: string; method: string; params: any };
- try {
- parsedMsg = JSON.parse(jsonMessage); // handle message and redirect to corresponding bridge
- sessionId = parsedMsg.id;
- message = parsedMsg.message;
- data = JSON.parse(message);
-
- // Update connected state
- this.connections[sessionId] = {
- ...this.connections[sessionId],
- connected: true,
- };
- } catch (error) {
- Logger.log(
- error,
- `AndroidService::onMessageReceived invalid json param`,
- );
- this.sendMessage({
- data: {
- error,
- jsonrpc: '2.0',
- },
- name: 'metamask-provider',
- }).catch((err) => {
- Logger.log(
- err,
- `AndroidService::onMessageReceived error sending jsonrpc error message to client ${sessionId}`,
- );
- });
- return;
- }
-
- let bridge = this.bridgeByClientId[sessionId];
-
- if (!bridge) {
- console.warn(
- `AndroidService:: Bridge not found for client`,
- `sessionId=${sessionId} data.id=${data.id}`,
- );
-
- try {
- // Ask users permissions again - it probably means the channel was removed
- await this.checkPermission({
- originatorInfo: this.connections[sessionId]?.originatorInfo ?? {},
- channelId: sessionId,
- });
-
- // Create new bridge
- this.setupBridge(this.connections[sessionId]);
- bridge = this.bridgeByClientId[sessionId];
- } catch (err) {
- Logger.log(
- err,
- `AndroidService::onMessageReceived error checking permissions`,
- );
- return;
- }
- }
-
- const accountsController = (
- Engine.context as {
- AccountsController: AccountsController;
- }
- ).AccountsController;
-
- const selectedInternalAccountChecksummedAddress = toChecksumHexAddress(
- accountsController.getSelectedAccount().address,
- );
-
- const networkController = (
- Engine.context as {
- NetworkController: NetworkController;
- }
- ).NetworkController;
-
- const {
- configuration: { chainId },
- } = networkController.getNetworkClientById(
- networkController.state?.selectedNetworkClientId,
- );
-
- this.currentClientId = sessionId;
-
- // Handle custom rpc method
- const processedRpc = await handleCustomRpcCalls({
- batchRPCManager: this.batchRPCManager,
- selectedChainId: chainId,
- selectedAddress: selectedInternalAccountChecksummedAddress,
- rpc: { id: data.id, method: data.method, params: data.params },
- });
-
- DevLogger.log(
- `AndroidService::onMessageReceived processedRpc`,
- processedRpc,
- );
- this.rpcQueueManager.add({
- id: processedRpc?.id ?? data.id,
- method: processedRpc?.method ?? data.method,
- });
- bridge.onMessage({ name: 'metamask-provider', data: processedRpc });
- };
- handleEventAsync().catch((err) => {
- Logger.log(
- err,
- `AndroidService::onMessageReceived error handling event`,
- );
- });
- });
- }
-
- private restorePreviousConnections() {
- if (Object.keys(this.connections ?? {}).length) {
- Object.values(this.connections).forEach((clientInfo) => {
- try {
- this.setupBridge(clientInfo);
- this.sendMessage(
- {
- type: MessageType.READY,
- data: {
- id: clientInfo?.clientId,
- },
- },
- false,
- ).catch((err) => {
- Logger.log(
- err,
- `AndroidService:: error sending jsonrpc error to client ${clientInfo.clientId}`,
- );
- });
- } catch (error) {
- Logger.log(
- error,
- `AndroidService:: error setting up bridge for client ${clientInfo.clientId}`,
- );
- }
- });
- }
- }
-
- private setupBridge(clientInfo: DappClient) {
- DevLogger.log(
- `AndroidService::setupBridge for id=${clientInfo.clientId} exists=${!!this
- .bridgeByClientId[clientInfo.clientId]}}`,
- );
-
- if (this.bridgeByClientId[clientInfo.clientId]) {
- return;
- }
-
- const defaultBridgeParams = getDefaultBridgeParams(clientInfo);
-
- const bridge = new BackgroundBridge({
- webview: null,
- channelId: clientInfo.clientId,
- isMMSDK: true,
- url: PROTOCOLS.METAMASK + '://' + AppConstants.MM_SDK.SDK_REMOTE_ORIGIN,
- isRemoteConn: true,
- sendMessage: this.sendMessage.bind(this),
- ...defaultBridgeParams,
- });
-
- this.bridgeByClientId[clientInfo.clientId] = bridge;
- }
-
- async removeConnection(channelId: string) {
- try {
- if (this.connections[channelId]) {
- DevLogger.log(
- `AndroidService::remove client ${channelId} exists --- remove bridge`,
- );
- delete this.bridgeByClientId[channelId];
- }
- delete this.connections[channelId];
- } catch (err) {
- Logger.log(err, `AndroidService::remove error`);
- }
- }
-
- // TODO: Replace "any" with type
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- async sendMessage(message: any, forceRedirect?: boolean) {
- return sendMessage(this, message, forceRedirect);
- }
-}
diff --git a/app/core/SDKConnect/AndroidSDK/AndroidService/sendMessage.test.ts b/app/core/SDKConnect/AndroidSDK/AndroidService/sendMessage.test.ts
deleted file mode 100644
index b92fbb7036f..00000000000
--- a/app/core/SDKConnect/AndroidSDK/AndroidService/sendMessage.test.ts
+++ /dev/null
@@ -1,143 +0,0 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
-import Logger from '../../../../util/Logger';
-import { createMockInternalAccount } from '../../../../util/test/accountsControllerTestUtils';
-import Engine from '../../../Engine';
-import { Minimizer } from '../../../NativeModules';
-import { RPC_METHODS } from '../../SDKConnectConstants';
-import handleBatchRpcResponse from '../../handlers/handleBatchRpcResponse';
-import { wait } from '../../utils/wait.util';
-import AndroidService from '../AndroidService';
-import sendMessage from './sendMessage';
-
-jest.mock('../../../Engine');
-jest.mock('../../../NativeModules', () => ({
- Minimizer: {
- goBack: jest.fn(),
- },
-}));
-jest.mock('../../../../util/Logger');
-jest.mock('../../utils/wait.util', () => ({
- wait: jest.fn().mockResolvedValue(undefined),
-}));
-jest.mock('../AndroidService');
-jest.mock('../../handlers/handleBatchRpcResponse', () => jest.fn());
-jest.mock('../../utils/DevLogger');
-
-const MOCK_ADDRESS = '0x1';
-const mockInternalAccount = createMockInternalAccount(
- MOCK_ADDRESS,
- 'Account 1',
-);
-
-describe('sendMessage', () => {
- let instance: jest.Mocked;
- let message: any;
-
- const mockGetId = jest.fn();
- const mockRemove = jest.fn();
- const mockIsEmpty = jest.fn().mockReturnValue(true);
- const mockGet = jest.fn();
- const mockSendMessage = jest.fn();
- const mockGetById = jest.fn();
-
- beforeEach(() => {
- jest.clearAllMocks();
-
- instance = {
- rpcQueueManager: {
- getId: mockGetId,
- remove: mockRemove,
- isEmpty: mockIsEmpty,
- get: mockGet,
- },
- communicationClient: {
- sendMessage: mockSendMessage,
- },
- batchRPCManager: {
- getById: mockGetById,
- },
- bridgeByClientId: {},
- currentClientId: 'test-client-id',
- } as unknown as jest.Mocked;
-
- message = {
- data: {
- id: 'test-id',
- result: ['0x1', '0x2'],
- },
- };
-
- (Engine.context as any) = {
- AccountsController: {
- getSelectedAccount: jest.fn().mockReturnValue(mockInternalAccount),
- },
- };
- });
-
- it('should send message with reordered accounts if selectedAddress is in result', async () => {
- mockGetId.mockReturnValue(RPC_METHODS.ETH_REQUESTACCOUNTS);
-
- await sendMessage(instance, message);
-
- expect(mockSendMessage).toHaveBeenCalledWith(
- JSON.stringify({
- ...message,
- data: {
- ...message.data,
- result: ['0x1', '0x2'],
- },
- }),
- );
- });
-
- it('should send message without reordering if selectedAddress is not in result', async () => {
- const MOCK_ADDRESS_2 = '0x3';
- const mockInternalAccount2 = createMockInternalAccount(
- MOCK_ADDRESS_2.toLowerCase(),
- 'Account 2',
- );
-
- (Engine.context as any).AccountsController.getSelectedAccount = jest
- .fn()
- .mockReturnValue(mockInternalAccount2);
-
- mockGetId.mockReturnValue(RPC_METHODS.ETH_REQUESTACCOUNTS);
-
- await sendMessage(instance, message);
-
- expect(mockSendMessage).toHaveBeenCalledWith(JSON.stringify(message));
- });
-
- it('should handle multichain rpc call responses separately', async () => {
- mockGetId.mockReturnValue('someMethod');
- mockGetById.mockReturnValue(['rpc1', 'rpc2']);
- (handleBatchRpcResponse as jest.Mock).mockResolvedValue(true);
-
- await sendMessage(instance, message);
-
- expect(handleBatchRpcResponse).toHaveBeenCalled();
- expect(mockRemove).toHaveBeenCalledWith('test-id');
- expect(mockSendMessage).toHaveBeenCalledWith(JSON.stringify(message));
- });
-
- it('should not call goBack if rpcQueueManager is not empty', async () => {
- mockGetId.mockReturnValue('someMethod');
- mockIsEmpty.mockReturnValue(false);
-
- await sendMessage(instance, message);
-
- expect(Minimizer.goBack).not.toHaveBeenCalled();
- });
-
- it('should handle error when waiting for empty rpc queue', async () => {
- mockGetId.mockReturnValue('someMethod');
- (wait as jest.Mock).mockRejectedValue(new Error('test error'));
-
- await sendMessage(instance, message);
-
- expect(Logger.log).toHaveBeenCalledWith(
- expect.any(Error),
- `AndroidService:: error waiting for empty rpc queue`,
- );
- });
-});
diff --git a/app/core/SDKConnect/AndroidSDK/AndroidService/sendMessage.ts b/app/core/SDKConnect/AndroidSDK/AndroidService/sendMessage.ts
deleted file mode 100644
index be7b9bd0915..00000000000
--- a/app/core/SDKConnect/AndroidSDK/AndroidService/sendMessage.ts
+++ /dev/null
@@ -1,132 +0,0 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
-import { AccountsController } from '@metamask/accounts-controller';
-import Logger from '../../../../util/Logger';
-import Engine from '../../../Engine';
-import { METHODS_TO_DELAY, RPC_METHODS } from '../../SDKConnectConstants';
-import handleBatchRpcResponse from '../../handlers/handleBatchRpcResponse';
-import DevLogger from '../../utils/DevLogger';
-import { wait } from '../../utils/wait.util';
-import AndroidService from '../AndroidService';
-import {
- areAddressesEqual,
- toFormattedAddress,
-} from '../../../../util/address';
-
-async function sendMessage(
- instance: AndroidService,
- message: any,
- forceRedirect?: boolean,
-) {
- const id = message?.data?.id;
- let rpcMethod = instance.rpcQueueManager.getId(id);
-
- const isConnectionResponse = rpcMethod === RPC_METHODS.ETH_REQUESTACCOUNTS;
-
- if (isConnectionResponse) {
- const accountsController = (
- Engine.context as {
- AccountsController: AccountsController;
- }
- ).AccountsController;
-
- const selectedFormattedAddress = toFormattedAddress(
- accountsController.getSelectedAccount().address,
- );
-
- const formattedAccounts = (message.data.result as string[]).map(
- (a: string) => toFormattedAddress(a),
- );
-
- const isPartOfConnectedAddresses = formattedAccounts.includes(
- selectedFormattedAddress,
- );
-
- if (isPartOfConnectedAddresses) {
- // Remove the selectedAddress from the formattedAccounts if it exists
- const remainingAccounts = formattedAccounts.filter(
- (account) => !areAddressesEqual(account, selectedFormattedAddress),
- );
-
- // Create the reorderedAccounts array with selectedAddress as the first element
- const reorderedAccounts: string[] = [
- selectedFormattedAddress,
- ...remainingAccounts,
- ];
-
- message = {
- ...message,
- data: {
- ...message.data,
- result: reorderedAccounts,
- },
- };
- }
- }
-
- instance.communicationClient.sendMessage(JSON.stringify(message));
-
- DevLogger.log(`AndroidService::sendMessage method=${rpcMethod}`, message);
-
- // handle multichain rpc call responses separately
- const chainRPCs = instance.batchRPCManager.getById(id);
- if (chainRPCs) {
- const isLastRpcOrError = await handleBatchRpcResponse({
- chainRpcs: chainRPCs,
- msg: message,
- backgroundBridge:
- instance.bridgeByClientId[instance.currentClientId ?? ''],
- batchRPCManager: instance.batchRPCManager,
- sendMessage: ({ msg }) => instance.sendMessage(msg),
- });
- DevLogger.log(
- `AndroidService::sendMessage isLastRpc=${isLastRpcOrError}`,
- chainRPCs,
- );
-
- if (!isLastRpcOrError) {
- DevLogger.log(
- `AndroidService::sendMessage NOT last rpc --- skip goBack()`,
- chainRPCs,
- );
- instance.rpcQueueManager.remove(id);
- // Only continue processing the message and goback if all rpcs in the batch have been handled
- return;
- }
-
- // Always set the method to metamask_batch otherwise it may not have been set correctly because of the batch rpc flow.
- rpcMethod = RPC_METHODS.METAMASK_BATCH;
- DevLogger.log(
- `AndroidService::sendMessage chainRPCs=${chainRPCs} COMPLETED!`,
- );
- }
-
- instance.rpcQueueManager.remove(id);
-
- if (!rpcMethod && forceRedirect !== true) {
- DevLogger.log(
- `AndroidService::sendMessage no rpc method --- rpcMethod=${rpcMethod} forceRedirect=${forceRedirect} --- skip goBack()`,
- );
- return;
- }
-
- try {
- if (METHODS_TO_DELAY[rpcMethod]) {
- // Add delay to see the feedback modal
- await wait(1000);
- }
-
- if (!instance.rpcQueueManager.isEmpty()) {
- DevLogger.log(
- `AndroidService::sendMessage NOT empty --- skip goBack()`,
- instance.rpcQueueManager.get(),
- );
- return;
- }
-
- DevLogger.log(`AndroidService::sendMessage empty --- goBack()`);
- } catch (error) {
- Logger.log(error, `AndroidService:: error waiting for empty rpc queue`);
- }
-}
-
-export default sendMessage;
diff --git a/app/core/SDKConnect/AndroidSDK/addDappConnection.test.ts b/app/core/SDKConnect/AndroidSDK/addDappConnection.test.ts
deleted file mode 100644
index fe4ddc84adb..00000000000
--- a/app/core/SDKConnect/AndroidSDK/addDappConnection.test.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { ConnectionProps } from '../Connection';
-import SDKConnect from '../SDKConnect';
-import addDappConnection from './addDappConnection';
-
-jest.mock('../Connection');
-jest.mock('../SDKConnect');
-jest.mock('../utils/DevLogger');
-jest.mock('../../../store/storage-wrapper', () => ({
- setItem: jest.fn().mockResolvedValue(''),
-}));
-jest.mock('../../../core/AppConstants');
-
-describe('addDappConnection', () => {
- let mockInstance = {} as unknown as SDKConnect;
- const mockEmit = jest.fn();
-
- beforeEach(() => {
- jest.clearAllMocks();
-
- mockInstance = {
- state: {
- connections: {},
- dappConnections: {},
- },
- emit: mockEmit,
- } as unknown as SDKConnect;
- });
-
- it('should add the connection to the instance state', async () => {
- const mockConnection = {
- id: 'test-id',
- } as unknown as ConnectionProps;
-
- await addDappConnection(mockConnection, mockInstance);
-
- expect(mockInstance.state.dappConnections[mockConnection.id]).toBe(
- mockConnection,
- );
- });
-});
diff --git a/app/core/SDKConnect/AndroidSDK/addDappConnection.ts b/app/core/SDKConnect/AndroidSDK/addDappConnection.ts
deleted file mode 100644
index 0f059de0e9a..00000000000
--- a/app/core/SDKConnect/AndroidSDK/addDappConnection.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { updateDappConnection } from '../../../actions/sdk';
-import { store } from '../../../store';
-import { ConnectionProps } from '../Connection';
-import SDKConnect from '../SDKConnect';
-import DevLogger from '../utils/DevLogger';
-
-async function addDappConnection(
- connection: ConnectionProps,
- instance: SDKConnect,
-) {
- instance.state.dappConnections[connection.id] = connection;
-
- DevLogger.log(`SDKConnect::addDappConnection`, connection);
-
- store.dispatch(updateDappConnection(connection.id, connection));
-}
-
-export default addDappConnection;
diff --git a/app/core/SDKConnect/AndroidSDK/bindAndroidSDK.test.ts b/app/core/SDKConnect/AndroidSDK/bindAndroidSDK.test.ts
deleted file mode 100644
index a53f94bd6b2..00000000000
--- a/app/core/SDKConnect/AndroidSDK/bindAndroidSDK.test.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import { NativeModules, Platform } from 'react-native';
-import SDKConnect from '../SDKConnect';
-import bindAndroidSDK from './bindAndroidSDK';
-
-jest.mock('../SDKConnect');
-jest.mock('../../../util/Logger');
-
-describe('bindAndroidSDK', () => {
- let mockInstance = {} as unknown as SDKConnect;
-
- beforeEach(() => {
- jest.clearAllMocks();
-
- NativeModules.CommunicationClient = {
- bindService: jest.fn(),
- };
-
- mockInstance = {
- state: {
- androidSDKBound: false,
- },
- } as unknown as SDKConnect;
- });
-
- it('should return early if the platform is not android', async () => {
- // TODO: Replace "any" with type
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (Platform as any).OS = 'ios';
-
- await bindAndroidSDK(mockInstance);
-
- expect(
- NativeModules.CommunicationClient.bindService,
- ).not.toHaveBeenCalled();
- });
-
- it('should return early if the Android SDK is already bound', async () => {
- mockInstance.state.androidSDKBound = true;
-
- await bindAndroidSDK(mockInstance);
-
- expect(
- NativeModules.CommunicationClient.bindService,
- ).not.toHaveBeenCalled();
- });
-});
diff --git a/app/core/SDKConnect/AndroidSDK/bindAndroidSDK.ts b/app/core/SDKConnect/AndroidSDK/bindAndroidSDK.ts
deleted file mode 100644
index fa3ede73666..00000000000
--- a/app/core/SDKConnect/AndroidSDK/bindAndroidSDK.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import Logger from '../../../util/Logger';
-import { NativeModules, Platform } from 'react-native';
-import SDKConnect from '../SDKConnect';
-
-async function bindAndroidSDK(instance: SDKConnect) {
- if (Platform.OS !== 'android') {
- return;
- }
-
- if (instance.state.androidSDKBound) return;
-
- try {
- // Always bind native module to client as early as possible otherwise connection may have an invalid status
- await NativeModules.CommunicationClient.bindService();
- instance.state.androidSDKBound = true;
- } catch (err) {
- Logger.log(err, `SDKConnect::bindAndroiSDK failed`);
- }
-}
-
-export default bindAndroidSDK;
diff --git a/app/core/SDKConnect/AndroidSDK/loadDappConnections.test.ts b/app/core/SDKConnect/AndroidSDK/loadDappConnections.test.ts
deleted file mode 100644
index 1922528a7b9..00000000000
--- a/app/core/SDKConnect/AndroidSDK/loadDappConnections.test.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import StorageWrapper from '../../../store/storage-wrapper';
-import loadDappConnections from './loadDappConnections';
-
-jest.mock('../../../core/AppConstants');
-jest.mock('../../../store/storage-wrapper', () => ({
- getItem: jest.fn().mockResolvedValue(''),
- setItem: jest.fn().mockResolvedValue(''),
-}));
-jest.mock('../utils/DevLogger');
-jest.mock('../../../store', () => ({
- store: {
- getState: jest.fn(() => ({
- sdk: {
- connections: {},
- approvedHosts: {},
- },
- })),
- },
-}));
-
-describe('loadDappConnections', () => {
- beforeEach(() => {
- jest.clearAllMocks();
- });
-
- it('should return an empty object if no connections are found', async () => {
- const result = await loadDappConnections();
-
- expect(result).toStrictEqual({});
- });
-
- it('should parse the retrieved connections', async () => {
- const mockConnections = {};
-
- (StorageWrapper.getItem as jest.Mock).mockResolvedValueOnce(
- JSON.stringify(mockConnections),
- );
-
- const result = await loadDappConnections();
-
- expect(result).toStrictEqual(mockConnections);
- });
-});
diff --git a/app/core/SDKConnect/AndroidSDK/loadDappConnections.ts b/app/core/SDKConnect/AndroidSDK/loadDappConnections.ts
deleted file mode 100644
index cbedc160281..00000000000
--- a/app/core/SDKConnect/AndroidSDK/loadDappConnections.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { RootState } from '../../../reducers';
-import { store } from '../../../store';
-import { ConnectionProps } from '../Connection';
-import DevLogger from '../utils/DevLogger';
-
-async function loadDappConnections(): Promise<{
- [id: string]: ConnectionProps;
-}> {
- const { sdk } = store.getState() as RootState;
-
- const dappConnections = sdk.dappConnections || {};
- DevLogger.log(
- `SDKConnect::loadDappConnections found ${
- Object.keys(dappConnections).length
- }`,
- dappConnections,
- );
- return dappConnections;
-}
-
-export default loadDappConnections;
diff --git a/app/core/SDKConnect/ConnectionManagement/removeChannel.ts b/app/core/SDKConnect/ConnectionManagement/removeChannel.ts
index 2e3d8b26a9b..778a2a35d96 100644
--- a/app/core/SDKConnect/ConnectionManagement/removeChannel.ts
+++ b/app/core/SDKConnect/ConnectionManagement/removeChannel.ts
@@ -30,7 +30,7 @@ async function removeChannel({
);
if (isDappConnection) {
- instance.state.androidService?.removeConnection(channelId);
+ // Android SDK disabled for security reasons (pm-security #532, #534)
instance.state.deeplinkingService?.removeConnection(channelId);
}
diff --git a/app/core/SDKConnect/InitializationManagement/init.ts b/app/core/SDKConnect/InitializationManagement/init.ts
index fcbdfd0401f..ef83210461c 100644
--- a/app/core/SDKConnect/InitializationManagement/init.ts
+++ b/app/core/SDKConnect/InitializationManagement/init.ts
@@ -1,6 +1,5 @@
import { NavigationContainerRef } from '@react-navigation/native';
import { Platform } from 'react-native';
-import AndroidService from '../AndroidSDK/AndroidService';
import SDKConnect from '../SDKConnect';
import DevLogger from '../utils/DevLogger';
import asyncInit from './asyncInit';
@@ -28,12 +27,6 @@ async function init({
return;
}
- if (!instance.state.androidSDKStarted && Platform.OS === 'android') {
- DevLogger.log(`SDKConnect::init() - starting android service`);
- instance.state.androidService = new AndroidService();
- instance.state.androidSDKStarted = true;
- }
-
if (!instance.state.deeplinkingServiceStarted && Platform.OS === 'ios') {
DevLogger.log(`SDKConnect::init() - starting deeplinking service`);
instance.state.deeplinkingService = new DeeplinkProtocolService();
diff --git a/app/core/SDKConnect/SDKConnect.test.ts b/app/core/SDKConnect/SDKConnect.test.ts
index 176bd70c0ee..f3f2bf0c3e2 100644
--- a/app/core/SDKConnect/SDKConnect.test.ts
+++ b/app/core/SDKConnect/SDKConnect.test.ts
@@ -1,8 +1,5 @@
import { OriginatorInfo } from '@metamask/sdk-communication-layer';
import AppConstants from '../AppConstants';
-import addDappConnection from './AndroidSDK/addDappConnection';
-import bindAndroidSDK from './AndroidSDK/bindAndroidSDK';
-import loadDappConnections from './AndroidSDK/loadDappConnections';
import { Connection, ConnectionProps } from './Connection';
import {
approveHost,
@@ -53,10 +50,6 @@ jest.mock('../NavigationService', () => ({
jest.mock('./Connection');
jest.mock('@react-navigation/native');
jest.mock('@metamask/sdk-communication-layer');
-jest.mock('./AndroidSDK/AndroidService');
-jest.mock('./AndroidSDK/addDappConnection');
-jest.mock('./AndroidSDK/bindAndroidSDK');
-jest.mock('./AndroidSDK/loadDappConnections');
jest.mock('./ConnectionManagement');
jest.mock('./InitializationManagement');
jest.mock('./RPCQueueManager');
@@ -122,18 +115,6 @@ describe('SDKConnect', () => {
typeof disapproveChannel
>;
- const mockBindAndroidSDK = bindAndroidSDK as jest.MockedFunction<
- typeof bindAndroidSDK
- >;
-
- const mockLoadDappConnections = loadDappConnections as jest.MockedFunction<
- typeof loadDappConnections
- >;
-
- const mockAddDappConnection = addDappConnection as jest.MockedFunction<
- typeof addDappConnection
- >;
-
beforeEach(() => {
jest.clearAllMocks();
sdkConnect = SDKConnect.getInstance();
@@ -317,36 +298,39 @@ describe('SDKConnect', () => {
});
});
- describe('Android SDK Management', () => {
+ describe('Android SDK Management (Disabled)', () => {
+ // Android SDK disabled for security reasons (pm-security #532, #534)
describe('bindAndroidSDK', () => {
- it('should bind the Android SDK', async () => {
- await sdkConnect.bindAndroidSDK();
+ it('should be a no-op (Android SDK disabled)', async () => {
+ const result = await sdkConnect.bindAndroidSDK();
+ expect(result).toBeUndefined();
+ });
+ });
- expect(mockBindAndroidSDK).toHaveBeenCalledTimes(1);
- expect(mockBindAndroidSDK).toHaveBeenCalledWith(sdkConnect);
+ describe('isAndroidSDKBound', () => {
+ it('should always return false (Android SDK disabled)', () => {
+ expect(sdkConnect.isAndroidSDKBound()).toBe(false);
});
});
describe('loadDappConnections', () => {
- it('should load Android connections', async () => {
- await sdkConnect.loadDappConnections();
+ it('should return empty object (Android SDK disabled)', async () => {
+ const result = await sdkConnect.loadDappConnections();
+ expect(result).toEqual({});
+ });
+ });
- expect(mockLoadDappConnections).toHaveBeenCalledTimes(1);
- expect(mockLoadDappConnections).toHaveBeenCalledWith();
+ describe('getAndroidConnections', () => {
+ it('should return undefined (Android SDK disabled)', () => {
+ expect(sdkConnect.getAndroidConnections()).toBeUndefined();
});
});
describe('addDappConnection', () => {
- it('should add an Android connection', async () => {
+ it('should be a no-op (Android SDK disabled)', async () => {
const testConnection = {} as ConnectionProps;
-
- await sdkConnect.addDappConnection(testConnection);
-
- expect(mockAddDappConnection).toHaveBeenCalledTimes(1);
- expect(mockAddDappConnection).toHaveBeenCalledWith(
- testConnection,
- sdkConnect,
- );
+ const result = await sdkConnect.addDappConnection(testConnection);
+ expect(result).toBeUndefined();
});
});
});
diff --git a/app/core/SDKConnect/SDKConnect.ts b/app/core/SDKConnect/SDKConnect.ts
index b3636cd4305..359c941dd77 100644
--- a/app/core/SDKConnect/SDKConnect.ts
+++ b/app/core/SDKConnect/SDKConnect.ts
@@ -6,10 +6,6 @@ import AppConstants from '../AppConstants';
import { OriginatorInfo } from '@metamask/sdk-communication-layer';
import { NavigationContainerRef } from '@react-navigation/native';
import Engine from '../../core/Engine';
-import AndroidService from './AndroidSDK/AndroidService';
-import addDappConnection from './AndroidSDK/addDappConnection';
-import bindAndroidSDK from './AndroidSDK/bindAndroidSDK';
-import loadDappConnections from './AndroidSDK/loadDappConnections';
import { Connection, ConnectionProps } from './Connection';
import {
approveHost,
@@ -72,7 +68,6 @@ export interface SDKConnectState {
connections: SDKSessions;
androidSDKStarted: boolean;
androidSDKBound: boolean;
- androidService?: AndroidService;
deeplinkingServiceStarted: boolean;
deeplinkingService?: DeeplinkProtocolService;
dappConnections: SDKSessions;
@@ -109,7 +104,6 @@ export class SDKConnect {
androidSDKStarted: false,
androidSDKBound: false,
deeplinkingServiceStarted: false,
- androidService: undefined,
deeplinkingService: undefined,
connecting: {},
approvedHosts: {},
@@ -222,26 +216,28 @@ export class SDKConnect {
return pause(this);
}
+ // Android SDK disabled for security reasons (pm-security #532, #534)
public async bindAndroidSDK() {
- return bindAndroidSDK(this);
+ return;
}
public isAndroidSDKBound() {
- return this.state.androidSDKBound;
+ return false;
}
async loadDappConnections(): Promise<{
[id: string]: ConnectionProps;
}> {
- return loadDappConnections();
+ return {};
}
getAndroidConnections() {
- return this.state.androidService?.getConnections();
+ return undefined;
}
- async addDappConnection(connection: ConnectionProps) {
- return addDappConnection(connection, this);
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ async addDappConnection(_connection: ConnectionProps) {
+ return;
}
public async refreshChannel({ channelId }: { channelId: string }) {
diff --git a/app/core/SDKConnect/SDKDeeplinkProtocol/DeeplinkProtocolService.test.ts b/app/core/SDKConnect/SDKDeeplinkProtocol/DeeplinkProtocolService.test.ts
index 9b7e991a689..dea4024abd7 100644
--- a/app/core/SDKConnect/SDKDeeplinkProtocol/DeeplinkProtocolService.test.ts
+++ b/app/core/SDKConnect/SDKDeeplinkProtocol/DeeplinkProtocolService.test.ts
@@ -9,7 +9,7 @@ import handleCustomRpcCalls from '../handlers/handleCustomRpcCalls';
import DevLogger from '../utils/DevLogger';
import DeeplinkProtocolService from './DeeplinkProtocolService';
import AppConstants from '../../AppConstants';
-import { DappClient } from '../AndroidSDK/dapp-sdk-types';
+import { DappClient } from '../dapp-sdk-types';
import { createMockInternalAccount } from '../../../util/test/accountsControllerTestUtils';
import { toChecksumHexAddress } from '@metamask/controller-utils';
diff --git a/app/core/SDKConnect/SDKDeeplinkProtocol/DeeplinkProtocolService.ts b/app/core/SDKConnect/SDKDeeplinkProtocol/DeeplinkProtocolService.ts
index 00fe77dc746..fbe5e69eb16 100644
--- a/app/core/SDKConnect/SDKDeeplinkProtocol/DeeplinkProtocolService.ts
+++ b/app/core/SDKConnect/SDKDeeplinkProtocol/DeeplinkProtocolService.ts
@@ -8,8 +8,8 @@ import AppConstants from '../../../core/AppConstants';
import Engine from '../../../core/Engine';
import Logger from '../../../util/Logger';
import BackgroundBridge from '../../BackgroundBridge/BackgroundBridge';
-import { DappClient, DappConnections } from '../AndroidSDK/dapp-sdk-types';
-import getDefaultBridgeParams from '../AndroidSDK/getDefaultBridgeParams';
+import { DappClient, DappConnections } from '../dapp-sdk-types';
+import getDefaultBridgeParams from '../getDefaultBridgeParams';
import BatchRPCManager from '../BatchRPCManager';
import RPCQueueManager from '../RPCQueueManager';
import SDKConnect from '../SDKConnect';
diff --git a/app/core/SDKConnect/AndroidSDK/dapp-sdk-types.ts b/app/core/SDKConnect/dapp-sdk-types.ts
similarity index 100%
rename from app/core/SDKConnect/AndroidSDK/dapp-sdk-types.ts
rename to app/core/SDKConnect/dapp-sdk-types.ts
diff --git a/app/core/SDKConnect/AndroidSDK/getDefaultBridgeParams.ts b/app/core/SDKConnect/getDefaultBridgeParams.ts
similarity index 93%
rename from app/core/SDKConnect/AndroidSDK/getDefaultBridgeParams.ts
rename to app/core/SDKConnect/getDefaultBridgeParams.ts
index 284c6b7dc65..df7b5945706 100644
--- a/app/core/SDKConnect/AndroidSDK/getDefaultBridgeParams.ts
+++ b/app/core/SDKConnect/getDefaultBridgeParams.ts
@@ -1,6 +1,6 @@
import { ImageSourcePropType } from 'react-native';
-import AppConstants from '../../AppConstants';
-import getRpcMethodMiddleware from '../../RPCMethods/RPCMethodMiddleware';
+import AppConstants from '../AppConstants';
+import getRpcMethodMiddleware from '../RPCMethods/RPCMethodMiddleware';
import { DappClient } from './dapp-sdk-types';
const getDefaultBridgeParams = (clientInfo: DappClient) => ({
diff --git a/app/core/SDKConnect/utils/wait.util.test.ts b/app/core/SDKConnect/utils/wait.util.test.ts
index be01a136b3d..72e0f7f3fe6 100644
--- a/app/core/SDKConnect/utils/wait.util.test.ts
+++ b/app/core/SDKConnect/utils/wait.util.test.ts
@@ -1,5 +1,5 @@
import { KeyringController } from '@metamask/keyring-controller';
-import { DappClient } from '../AndroidSDK/dapp-sdk-types';
+import { DappClient } from '../dapp-sdk-types';
import { Connection } from '../Connection';
import RPCQueueManager from '../RPCQueueManager';
import { SDKConnect } from '../SDKConnect';
diff --git a/app/core/SDKConnect/utils/wait.util.ts b/app/core/SDKConnect/utils/wait.util.ts
index d95753f41ee..86cd0b17743 100644
--- a/app/core/SDKConnect/utils/wait.util.ts
+++ b/app/core/SDKConnect/utils/wait.util.ts
@@ -1,5 +1,5 @@
import { KeyringController } from '@metamask/keyring-controller';
-import { DappClient } from '../AndroidSDK/dapp-sdk-types';
+import { DappClient } from '../dapp-sdk-types';
import RPCQueueManager from '../RPCQueueManager';
import { SDKConnect } from '../SDKConnect';
import DevLogger from './DevLogger';
diff --git a/app/images/reveal_srp.png b/app/images/reveal_srp.png
new file mode 100644
index 00000000000..5dc4b91aa35
Binary files /dev/null and b/app/images/reveal_srp.png differ
diff --git a/docs/readme/release-build-profiler.md b/docs/readme/release-build-profiler.md
index 75ec8400adb..aed8bc69434 100644
--- a/docs/readme/release-build-profiler.md
+++ b/docs/readme/release-build-profiler.md
@@ -37,6 +37,14 @@ Chrome's tracing UI expects a JSON trace. Convert the `.cpuprofile` first:
yarn react-native-release-profiler --local /path/to/profile.cpuprofile
```
+To have sourcemaps on the tracing, to be easier to identify the processes that are happening, convirt the `.cpuprofile` with this argument:
+
+```bash
+yarn react-native-release-profiler --local /path/to/profile.cpuprofile --sourcemap-path /path/to/sourcemaps
+```
+
+You can find the sourcemaps at the artifcacts generated when running `release_rc_builds_to_store_pipeline` in bitrise, under the name `Android_Sourcemaps_prodRelease.zip`, download and unzip it.
+
Then open Chrome and load the generated JSON:
- Navigate to `chrome://tracing` → Load → select the JSON file.
diff --git a/e2e/framework/services/providers/browserstack/BrowserStackConfigBuilder.ts b/e2e/framework/services/providers/browserstack/BrowserStackConfigBuilder.ts
index c9c00497e75..277b0142912 100644
--- a/e2e/framework/services/providers/browserstack/BrowserStackConfigBuilder.ts
+++ b/e2e/framework/services/providers/browserstack/BrowserStackConfigBuilder.ts
@@ -70,14 +70,24 @@ export class BrowserStackConfigBuilder {
'appium:app': appBsUrl,
'appium:autoAcceptAlerts': true,
'appium:fullReset': true,
+ 'appium:settings[actionAcknowledgmentTimeout]': 3000,
+ 'appium:settings[ignoreUnimportantViews]': true,
'appium:settings[snapshotMaxDepth]': 62,
+ 'appium:settings[waitForSelectorTimeout]': 1000,
'appium:includeSafariInWebviews': true,
'appium:chromedriverAutodownload': true,
+ 'appium:waitForQuiescence': false, // Don't wait for app idle
+ 'appium:animationCoolOffTimeout': 0, // Skip animation wait
+ 'appium:reduceMotion': true, // Reduce iOS animations
+ 'appium:customSnapshotTimeout': 15, // Snapshot timeout in seconds"
+ 'appium:waitForIdleTimeout': 0, // Don't wait for idle
+ 'appium:disableWindowAnimation': true, // Disable animations
+ 'appium:skipDeviceInitialization': true, // Skip init (faster startup)
'appium:bstackPageSource': {
enable: true,
- samplesX: 15,
- samplesY: 15,
- maxDepth: 75,
+ samplesX: 3,
+ samplesY: 3,
+ maxDepth: 15,
},
},
};
diff --git a/e2e/selectors/Perps/Perps.selectors.ts b/e2e/selectors/Perps/Perps.selectors.ts
index 656813b533f..4f09fe707f3 100644
--- a/e2e/selectors/Perps/Perps.selectors.ts
+++ b/e2e/selectors/Perps/Perps.selectors.ts
@@ -456,6 +456,7 @@ export const PerpsBottomSheetTooltipSelectorsIDs = {
export const PerpsTutorialSelectorsIDs = {
CONTINUE_BUTTON: 'perps-tutorial-continue-button',
SKIP_BUTTON: 'perps-tutorial-skip-button',
+ LEARN_MORE_BUTTON: 'perps-tutorial-learn-more-button',
CAROUSEL: 'perps-tutorial-carousel',
CHARACTER_IMAGE: 'perps-tutorial-character-image',
TUTORIAL_CARD: 'perps-tutorial-card',
diff --git a/locales/languages/en.json b/locales/languages/en.json
index 2b7db4eec99..5968f388a57 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -1212,8 +1212,9 @@
"market_price": "Market price: {{price}}",
"market": "Market",
"current_price": "Current price",
- "ask_price": "Ask price",
- "bid_price": "Bid price",
+ "mid_price": "Mid",
+ "ask_price": "Ask",
+ "bid_price": "Bid",
"difference_from_market": "Difference from market:",
"limit_price_above": "Limit price is above current price",
"limit_price_below": "Limit price is below current price"
@@ -1913,6 +1914,7 @@
},
"got_it": "Got it",
"lets_go": "Let's go",
+ "learn_more": "Learn more",
"card": {
"title": "Learn the basics of perps"
}
@@ -3153,7 +3155,7 @@
"Your",
"Secret Recovery Phrase",
"gives",
- "full access to your wallet, funds and accounts.\n\n",
+ "full access to your wallet, funds and accounts.",
"MetaMask is a",
"non-custodial wallet.",
"That means,",
@@ -3188,8 +3190,8 @@
"unknown_error": "Couldn't unlock your account. Please try again.",
"hardware_error": "This is a hardware wallet account, you cannot export your private key.",
"seed_warning": "This is your wallet's 12 word phrase. This phrase can be used to take control of all of your current and future accounts, including the ability to send away any of their funds. Keep this phrase stored safely, DO NOT share it with anyone.",
- "text": "TEXT",
- "qr_code": "QR CODE",
+ "text": "Text",
+ "qr_code": "QR code",
"hold_to_reveal_credential": "Hold to reveal {{credentialName}}",
"reveal_credential": "Reveal {{credentialName}}",
"keep_credential_safe": "Keep your {{credentialName}} safe",
@@ -6490,6 +6492,7 @@
"card": {
"card": "MetaMask Card",
"card_button_enabled_toast": "You can now sign up for MetaMask Card!",
+ "card_button_already_enabled_toast": "You already have a MetaMask Card linked to this account.",
"add_funds_bottomsheet": {
"deposit": "Fund with cash",
"deposit_description": "Low-cost card or bank transfer",
diff --git a/package.json b/package.json
index 93047dc48b8..fe3b4a348e9 100644
--- a/package.json
+++ b/package.json
@@ -291,7 +291,7 @@
"@metamask/token-search-discovery-controller": "^4.0.0",
"@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.6.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch",
"@metamask/transaction-pay-controller": "^10.5.0",
- "@metamask/tron-wallet-snap": "^1.16.0",
+ "@metamask/tron-wallet-snap": "^1.16.1",
"@metamask/utils": "^11.8.1",
"@ngraveio/bc-ur": "^1.1.6",
"@nktkas/hyperliquid": "^0.27.1",
diff --git a/yarn.lock b/yarn.lock
index b6fc99ae0bc..4653cbaf763 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -9608,10 +9608,10 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/tron-wallet-snap@npm:^1.16.0":
- version: 1.16.0
- resolution: "@metamask/tron-wallet-snap@npm:1.16.0"
- checksum: 10/86be8ef0b7258b8375b9ab43eb4a8f55018f02a226a3784b727a25c03c249afa75e9adacecbf72f982dbf720db4107882a1b8304634bc5dad25ce2db760beff0
+"@metamask/tron-wallet-snap@npm:^1.16.1":
+ version: 1.16.1
+ resolution: "@metamask/tron-wallet-snap@npm:1.16.1"
+ checksum: 10/f6e871c911edc22af6955676e190a7479446a1663ebecdbd69ee2cf31611a3721a162ed8d6a9d8857f44809a6c4973dd1b9c33c067a5168297c62a21f7cfa44c
languageName: node
linkType: hard
@@ -34263,7 +34263,7 @@ __metadata:
"@metamask/token-search-discovery-controller": "npm:^4.0.0"
"@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.6.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch"
"@metamask/transaction-pay-controller": "npm:^10.5.0"
- "@metamask/tron-wallet-snap": "npm:^1.16.0"
+ "@metamask/tron-wallet-snap": "npm:^1.16.1"
"@metamask/utils": "npm:^11.8.1"
"@ngraveio/bc-ur": "npm:^1.1.6"
"@nktkas/hyperliquid": "npm:^0.27.1"