diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md index 6afc0813038..d8860444ade 100644 --- a/.cursor/BUGBOT.md +++ b/.cursor/BUGBOT.md @@ -17,7 +17,7 @@ Use the rules in the [unit testing guidelines](rules/unit-testing-guidelines.mdc ### 2. Initial Setup - E2E Tests - **ALWAYS** load and reference [e2e-testing-guidelines](rules/e2e-testing-guidelines.mdc) -- Verify test file naming pattern: `e2e/specs/**/*.spec.{js,ts}` +- Verify test file naming pattern: `tests/(smoke|regression)/**/*.spec.{js,ts}` - Check for proper imports and framework utilities from `tests/framework/index.ts` Use the rules in the [e2e-testing-guidelines](rules/e2e-testing-guidelines.mdc) to enforce the test quality and bug detection. diff --git a/.cursor/rules/e2e-testing-guidelines.mdc b/.cursor/rules/e2e-testing-guidelines.mdc index 665a331596b..62ad1515d2d 100644 --- a/.cursor/rules/e2e-testing-guidelines.mdc +++ b/.cursor/rules/e2e-testing-guidelines.mdc @@ -26,14 +26,15 @@ alwaysApply: true ## Test Organization - MANDATORY - Organize tests into folders based on features and scenarios +- Use the a directory that suits the test type (regression|smoke) based on the tag used - Each feature team should own one or more folders of tests - Follow the same organization pattern as the extension team for consistency - Place tests in logical feature directories: ``` - e2e/specs// - e2e/specs/tokens/import/import-erc1155.spec.ts - e2e/specs/settings/clear-activity.spec.ts - e2e/specs/ppom/ppom-blockaid-alert-erc20-approval.spec.ts + tests/smoke// + tests/smoke/tokens/import/import-erc1155.spec.ts + tests/regression/wallet/settings/clear-activity.spec.ts + tests/regression/ppom/ppom-blockaid-alert-erc20-approval.spec.ts ``` ## Framework Architecture @@ -109,6 +110,7 @@ new FixtureBuilder().build(); ### Page Object Model (POM) Pattern - ALWAYS use the Page Object Model pattern for organizing test code - Move all element selectors to Page Objects or dedicated selector files +- When adding one or more testID to a component or view, place it in a dedicated file next to where it is being used with the file extension `.testIds.ts` - Access UI elements through Page Object methods, not directly in test specs #### Page Object Structure Example: @@ -149,6 +151,21 @@ class LoginPage { export default new LoginPage(); ``` +### TestIDs location example: +```typescript +// DON'T: +import { MyComponentSelectors } from '../../tests/selectors/Card/RecurringFeeModal.selectors'; + +// DO: +import { MyComponentSelectors } from './MyComponent.testIds'; + +const MyComponent = () => { + return ( + + ) +}; +``` + ### Proper Waiting and Assertions - NEVER use `TestHelpers.delay()` - it creates flaky tests and slows down test execution - ALWAYS use proper waiting with Assertions from the framework: diff --git a/.github/scripts/e2e-extract-test-results.mjs b/.github/scripts/e2e-extract-test-results.mjs index cdaa551f0d8..18e64bb7dd8 100644 --- a/.github/scripts/e2e-extract-test-results.mjs +++ b/.github/scripts/e2e-extract-test-results.mjs @@ -48,7 +48,7 @@ async function parseXml(content) { * jest-junit outputs XML with structure: * * - * + * * ... * * diff --git a/.github/scripts/e2e-split-tags-shards.mjs b/.github/scripts/e2e-split-tags-shards.mjs index 87d6522fc7b..216698c770b 100644 --- a/.github/scripts/e2e-split-tags-shards.mjs +++ b/.github/scripts/e2e-split-tags-shards.mjs @@ -11,10 +11,7 @@ import { extractTestResults } from './e2e-extract-test-results.mjs'; const env = { TEST_SUITE_TAG: process.env.TEST_SUITE_TAG, - // Starting at the root drastically affects the performance of the script. - // This will be reverted as soon as all specs are migrated to the new folder - // structure. - BASE_DIR: process.env.BASE_DIR || './', + BASE_DIR: process.env.BASE_DIR || './tests/', METAMASK_BUILD_TYPE: process.env.METAMASK_BUILD_TYPE || 'main', PLATFORM: process.env.PLATFORM || 'ios', SPLIT_NUMBER: Number(process.env.SPLIT_NUMBER || '1'), diff --git a/.github/workflows/run-performance-e2e-experimental.yml b/.github/workflows/run-performance-e2e-experimental.yml index 7e6dc967ddf..a5793626bb0 100644 --- a/.github/workflows/run-performance-e2e-experimental.yml +++ b/.github/workflows/run-performance-e2e-experimental.yml @@ -9,6 +9,11 @@ on: branches: - main +permissions: + contents: read + id-token: write + actions: write + concurrency: group: performance-e2e-experimental-${{ github.ref }}-${{ github.event_name }} cancel-in-progress: false diff --git a/.storybook/storybook.requires.js b/.storybook/storybook.requires.js index 398722c95af..73f94dfe3d1 100644 --- a/.storybook/storybook.requires.js +++ b/.storybook/storybook.requires.js @@ -47,7 +47,7 @@ const getStories = () => { "./app/component-library/components-temp/Buttons/ButtonSemantic/ButtonSemantic.stories.tsx": require("../app/component-library/components-temp/Buttons/ButtonSemantic/ButtonSemantic.stories.tsx"), "./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/HeaderCompactStandard/HeaderCompactStandard.stories.tsx": require("../app/component-library/components-temp/HeaderCompactStandard/HeaderCompactStandard.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/HeaderWithTitleLeftScrollable/HeaderWithTitleLeftScrollable.stories.tsx": require("../app/component-library/components-temp/HeaderWithTitleLeftScrollable/HeaderWithTitleLeftScrollable.stories.tsx"), "./app/component-library/components-temp/KeyValueRow/KeyValueRow.stories.tsx": require("../app/component-library/components-temp/KeyValueRow/KeyValueRow.stories.tsx"), @@ -68,6 +68,8 @@ const getStories = () => { "./app/component-library/components-temp/Tabs/Tabs.stories.tsx": require("../app/component-library/components-temp/Tabs/Tabs.stories.tsx"), "./app/component-library/components-temp/TagColored/TagColored.stories.tsx": require("../app/component-library/components-temp/TagColored/TagColored.stories.tsx"), "./app/component-library/components-temp/TitleLeft/TitleLeft.stories.tsx": require("../app/component-library/components-temp/TitleLeft/TitleLeft.stories.tsx"), + "./app/component-library/components-temp/TitleStandard/TitleStandard.stories.tsx": require("../app/component-library/components-temp/TitleStandard/TitleStandard.stories.tsx"), + "./app/component-library/components-temp/TitleSubpage/TitleSubpage.stories.tsx": require("../app/component-library/components-temp/TitleSubpage/TitleSubpage.stories.tsx"), "./app/component-library/components/Accordions/Accordion/Accordion.stories.tsx": require("../app/component-library/components/Accordions/Accordion/Accordion.stories.tsx"), "./app/component-library/components/Accordions/Accordion/foundation/AccordionHeader/AccordionHeader.stories.tsx": require("../app/component-library/components/Accordions/Accordion/foundation/AccordionHeader/AccordionHeader.stories.tsx"), "./app/component-library/components/Avatars/Avatar/Avatar.stories.tsx": require("../app/component-library/components/Avatars/Avatar/Avatar.stories.tsx"), diff --git a/android/app/build.gradle b/android/app/build.gradle index a206ec5276d..dc210b17267 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -187,7 +187,7 @@ android { applicationId "io.metamask" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionName "7.65.0" + versionName "7.66.0" versionCode 3607 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/component-library/components-temp/ConditionalScrollView/ConditionalScrollView.tsx b/app/component-library/components-temp/ConditionalScrollView/ConditionalScrollView.tsx index c6ab4878ca1..8ad68ae3820 100644 --- a/app/component-library/components-temp/ConditionalScrollView/ConditionalScrollView.tsx +++ b/app/component-library/components-temp/ConditionalScrollView/ConditionalScrollView.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { forwardRef } from 'react'; import { ScrollView } from 'react-native'; import { ConditionalScrollViewProps } from './ConditionalScrollView.types'; @@ -6,15 +6,19 @@ import { ConditionalScrollViewProps } from './ConditionalScrollView.types'; * ConditionalScrollView renders either a ScrollView or content directly based on isScrollEnabled prop. * This is useful for homepage redesign where we want to remove nested scroll views in favor of a global scroll container. */ -const ConditionalScrollView: React.FC = ({ - children, - isScrollEnabled, - scrollViewProps, -}) => +const ConditionalScrollView = forwardRef< + ScrollView, + ConditionalScrollViewProps +>(({ children, isScrollEnabled, scrollViewProps }, ref) => isScrollEnabled ? ( - {children} + + {children} + ) : ( <>{children} - ); + ), +); + +ConditionalScrollView.displayName = 'ConditionalScrollView'; export default ConditionalScrollView; diff --git a/app/component-library/components-temp/HeaderCenter/getHeaderCenterNavbarOptions.tsx b/app/component-library/components-temp/HeaderCenter/getHeaderCenterNavbarOptions.tsx deleted file mode 100644 index 5df40dcd7f9..00000000000 --- a/app/component-library/components-temp/HeaderCenter/getHeaderCenterNavbarOptions.tsx +++ /dev/null @@ -1,32 +0,0 @@ -// Third party dependencies. -import React from 'react'; - -// Internal dependencies. -import HeaderCenter from './HeaderCenter'; -import { HeaderCenterProps } from './HeaderCenter.types'; - -/** - * Returns React Navigation screen options with a HeaderCenter component. - * - * @example - * ```tsx - * const options = getHeaderCenterNavbarOptions({ - * title: 'Settings', - * onBack: () => navigation.goBack(), - * onClose: () => navigation.pop(), - * includesTopInset: true, - * }); - * - * - * ``` - * - * @param options - Props to pass to the HeaderCenter component. - * @returns React Navigation screen options object with header property. - */ -const getHeaderCenterNavbarOptions = ( - options: HeaderCenterProps, -): { header: () => React.ReactElement } => ({ - header: () => , -}); - -export default getHeaderCenterNavbarOptions; diff --git a/app/component-library/components-temp/HeaderCenter/index.ts b/app/component-library/components-temp/HeaderCenter/index.ts deleted file mode 100644 index f6d9784b349..00000000000 --- a/app/component-library/components-temp/HeaderCenter/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { default } from './HeaderCenter'; -export { default as getHeaderCenterNavbarOptions } from './getHeaderCenterNavbarOptions'; -export type { HeaderCenterProps } from './HeaderCenter.types'; diff --git a/app/component-library/components-temp/HeaderCenter/HeaderCenter.stories.tsx b/app/component-library/components-temp/HeaderCompactStandard/HeaderCompactStandard.stories.tsx similarity index 73% rename from app/component-library/components-temp/HeaderCenter/HeaderCenter.stories.tsx rename to app/component-library/components-temp/HeaderCompactStandard/HeaderCompactStandard.stories.tsx index fc213795a89..ac1b424fdaa 100644 --- a/app/component-library/components-temp/HeaderCenter/HeaderCenter.stories.tsx +++ b/app/component-library/components-temp/HeaderCompactStandard/HeaderCompactStandard.stories.tsx @@ -8,11 +8,11 @@ import { IconName, } from '@metamask/design-system-react-native'; -import HeaderCenter from './HeaderCenter'; +import HeaderCompactStandard from './HeaderCompactStandard'; -const HeaderCenterMeta = { - title: 'Components Temp / HeaderCenter', - component: HeaderCenter, +const HeaderCompactStandardMeta = { + title: 'Components Temp / HeaderCompactStandard', + component: HeaderCompactStandard, argTypes: { title: { control: 'text', @@ -26,7 +26,7 @@ const HeaderCenterMeta = { }, }; -export default HeaderCenterMeta; +export default HeaderCompactStandardMeta; export const Default = { args: { @@ -36,13 +36,16 @@ export const Default = { export const OnBack = { render: () => ( - console.log('Back pressed')} /> + console.log('Back pressed')} + /> ), }; export const OnClose = { render: () => ( - console.log('Close pressed')} /> @@ -51,7 +54,7 @@ export const OnClose = { export const BackAndClose = { render: () => ( - console.log('Back pressed')} onClose={() => console.log('Close pressed')} @@ -61,7 +64,7 @@ export const BackAndClose = { export const WithSubtitle = { render: () => ( - console.log('Back pressed')} @@ -71,7 +74,7 @@ export const WithSubtitle = { export const MultipleEndButtons = { render: () => ( - console.log('Back pressed')} endButtonIconProps={[ @@ -87,11 +90,11 @@ export const MultipleEndButtons = { export const Children = { render: () => ( - console.log('Close pressed')}> + console.log('Close pressed')}> Custom Title Subtitle text - + ), }; diff --git a/app/component-library/components-temp/HeaderCenter/HeaderCenter.test.tsx b/app/component-library/components-temp/HeaderCompactStandard/HeaderCompactStandard.test.tsx similarity index 85% rename from app/component-library/components-temp/HeaderCenter/HeaderCenter.test.tsx rename to app/component-library/components-temp/HeaderCompactStandard/HeaderCompactStandard.test.tsx index 36ce4677d27..b85e360c193 100644 --- a/app/component-library/components-temp/HeaderCenter/HeaderCenter.test.tsx +++ b/app/component-library/components-temp/HeaderCompactStandard/HeaderCompactStandard.test.tsx @@ -7,30 +7,32 @@ import { Text } from 'react-native'; import { IconName } from '@metamask/design-system-react-native'; // Internal dependencies. -import HeaderCenter from './HeaderCenter'; +import HeaderCompactStandard from './HeaderCompactStandard'; -const CONTAINER_TEST_ID = 'header-center-container'; -const TITLE_TEST_ID = 'header-center-title'; -const BACK_BUTTON_TEST_ID = 'header-center-back-button'; -const CLOSE_BUTTON_TEST_ID = 'header-center-close-button'; +const CONTAINER_TEST_ID = 'header-compact-standard-container'; +const TITLE_TEST_ID = 'header-compact-standard-title'; +const BACK_BUTTON_TEST_ID = 'header-compact-standard-back-button'; +const CLOSE_BUTTON_TEST_ID = 'header-compact-standard-close-button'; const START_ACCESSORY_TEST_ID = 'start-accessory-wrapper'; const END_ACCESSORY_TEST_ID = 'end-accessory-wrapper'; -describe('HeaderCenter', () => { +describe('HeaderCompactStandard', () => { beforeEach(() => { jest.clearAllMocks(); }); describe('rendering', () => { it('renders with title', () => { - const { getByText } = render(); + const { getByText } = render( + , + ); expect(getByText('Test Title')).toBeOnTheScreen(); }); it('renders title with testID when provided via titleProps', () => { const { getByTestId } = render( - , @@ -41,7 +43,7 @@ describe('HeaderCenter', () => { it('renders container with testID when provided', () => { const { getByTestId } = render( - , + , ); expect(getByTestId(CONTAINER_TEST_ID)).toBeOnTheScreen(); @@ -49,9 +51,9 @@ describe('HeaderCenter', () => { it('renders custom children instead of title', () => { const { getByText, queryByText } = render( - + Custom Content - , + , ); expect(getByText('Custom Content')).toBeOnTheScreen(); @@ -60,9 +62,9 @@ describe('HeaderCenter', () => { it('renders children when both title and children provided', () => { const { getByText, queryByText } = render( - + Children Text - , + , ); expect(getByText('Children Text')).toBeOnTheScreen(); @@ -71,21 +73,23 @@ describe('HeaderCenter', () => { it('renders subtitle when provided', () => { const { getByText } = render( - , + , ); expect(getByText('Test Subtitle')).toBeOnTheScreen(); }); it('does not render subtitle when not provided', () => { - const { queryByText } = render(); + const { queryByText } = render( + , + ); expect(queryByText('Test Subtitle')).not.toBeOnTheScreen(); }); it('renders subtitle with testID when provided via subtitleProps', () => { const { getByTestId } = render( - { it('renders both title and subtitle together', () => { const { getByText } = render( - , + , ); expect(getByText('Main Title')).toBeOnTheScreen(); @@ -108,7 +112,7 @@ describe('HeaderCenter', () => { describe('back button', () => { it('renders back button when onBack provided', () => { const { getByTestId } = render( - { it('renders back button when backButtonProps provided', () => { const { getByTestId } = render( - , @@ -132,7 +136,7 @@ describe('HeaderCenter', () => { it('calls onBack when back button pressed', () => { const onBack = jest.fn(); const { getByTestId } = render( - { it('calls backButtonProps.onPress when back button pressed', () => { const onPress = jest.fn(); const { getByTestId } = render( - , @@ -162,7 +166,7 @@ describe('HeaderCenter', () => { const onBack = jest.fn(); const onPress = jest.fn(); const { getByTestId } = render( - { it('does not render start accessory when no back button props provided', () => { const { queryByTestId } = render( - , @@ -189,7 +193,7 @@ describe('HeaderCenter', () => { it('renders startButtonIconProps when provided', () => { const onPress = jest.fn(); const { getByTestId } = render( - { const onBack = jest.fn(); const onPress = jest.fn(); const { getByTestId, queryByTestId } = render( - { describe('close button', () => { it('renders close button when onClose provided', () => { const { getByTestId } = render( - { it('renders close button when closeButtonProps provided', () => { const { getByTestId } = render( - { it('calls onClose when close button pressed', () => { const onClose = jest.fn(); const { getByTestId } = render( - { it('calls closeButtonProps.onPress when close button pressed', () => { const onPress = jest.fn(); const { getByTestId } = render( - , @@ -283,7 +287,7 @@ describe('HeaderCenter', () => { const onClose = jest.fn(); const onPress = jest.fn(); const { getByTestId } = render( - { it('does not render end accessory when no close button props provided', () => { const { queryByTestId } = render( - , @@ -311,7 +315,7 @@ describe('HeaderCenter', () => { describe('props forwarding', () => { it('renders start accessory when onBack is provided', () => { const { getByTestId } = render( - { it('forwards endButtonIconProps and adds close button', () => { const { getByTestId } = render( - { it('accepts custom testID', () => { const { getByTestId } = render( - , + , ); expect(getByTestId('custom-header')).toBeOnTheScreen(); diff --git a/app/component-library/components-temp/HeaderCenter/HeaderCenter.tsx b/app/component-library/components-temp/HeaderCompactStandard/HeaderCompactStandard.tsx similarity index 90% rename from app/component-library/components-temp/HeaderCenter/HeaderCenter.tsx rename to app/component-library/components-temp/HeaderCompactStandard/HeaderCompactStandard.tsx index 190a148abc7..2e11a54101f 100644 --- a/app/component-library/components-temp/HeaderCenter/HeaderCenter.tsx +++ b/app/component-library/components-temp/HeaderCompactStandard/HeaderCompactStandard.tsx @@ -15,29 +15,29 @@ import { // Internal dependencies. import HeaderBase from '../../components/HeaderBase'; -import { HeaderCenterProps } from './HeaderCenter.types'; +import { HeaderCompactStandardProps } from './HeaderCompactStandard.types'; /** - * HeaderCenter is a header component with centered title and optional back/close buttons. + * HeaderCompactStandard is a header component with centered title and optional back/close buttons. * Extends HeaderBase with convenient props for common header patterns. * * @example * ```tsx - * * * // Or with custom button props - * * ``` */ -const HeaderCenter: React.FC = ({ +const HeaderCompactStandard: React.FC = ({ title, titleProps, subtitle, @@ -135,4 +135,4 @@ const HeaderCenter: React.FC = ({ ); }; -export default HeaderCenter; +export default HeaderCompactStandard; diff --git a/app/component-library/components-temp/HeaderCenter/HeaderCenter.types.ts b/app/component-library/components-temp/HeaderCompactStandard/HeaderCompactStandard.types.ts similarity index 93% rename from app/component-library/components-temp/HeaderCenter/HeaderCenter.types.ts rename to app/component-library/components-temp/HeaderCompactStandard/HeaderCompactStandard.types.ts index 4653a9253ad..04a813f4f8b 100644 --- a/app/component-library/components-temp/HeaderCenter/HeaderCenter.types.ts +++ b/app/component-library/components-temp/HeaderCompactStandard/HeaderCompactStandard.types.ts @@ -8,9 +8,9 @@ import { import { HeaderBaseProps } from '../../components/HeaderBase'; /** - * HeaderCenter component props. + * HeaderCompactStandard component props. */ -export interface HeaderCenterProps extends HeaderBaseProps { +export interface HeaderCompactStandardProps extends HeaderBaseProps { /** * Title text to display in the header. * Used as children if children prop is not provided. diff --git a/app/component-library/components-temp/HeaderCenter/getHeaderCenterNavbarOptions.test.tsx b/app/component-library/components-temp/HeaderCompactStandard/getHeaderCompactStandardNavbarOptions.test.tsx similarity index 66% rename from app/component-library/components-temp/HeaderCenter/getHeaderCenterNavbarOptions.test.tsx rename to app/component-library/components-temp/HeaderCompactStandard/getHeaderCompactStandardNavbarOptions.test.tsx index 924722f4345..59ed2c71f81 100644 --- a/app/component-library/components-temp/HeaderCenter/getHeaderCenterNavbarOptions.test.tsx +++ b/app/component-library/components-temp/HeaderCompactStandard/getHeaderCompactStandardNavbarOptions.test.tsx @@ -3,27 +3,27 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; // Internal dependencies. -import getHeaderCenterNavbarOptions from './getHeaderCenterNavbarOptions'; +import getHeaderCompactStandardNavbarOptions from './getHeaderCompactStandardNavbarOptions'; -const TITLE_TEST_ID = 'header-center-title'; -const BACK_BUTTON_TEST_ID = 'header-center-back-button'; -const CLOSE_BUTTON_TEST_ID = 'header-center-close-button'; +const TITLE_TEST_ID = 'header-compact-standard-title'; +const BACK_BUTTON_TEST_ID = 'header-compact-standard-back-button'; +const CLOSE_BUTTON_TEST_ID = 'header-compact-standard-close-button'; -describe('getHeaderCenterNavbarOptions', () => { +describe('getHeaderCompactStandardNavbarOptions', () => { beforeEach(() => { jest.clearAllMocks(); }); describe('return value', () => { it('returns object with header function', () => { - const options = getHeaderCenterNavbarOptions({ title: 'Test' }); + const options = getHeaderCompactStandardNavbarOptions({ title: 'Test' }); expect(options).toHaveProperty('header'); expect(typeof options.header).toBe('function'); }); it('returns React element when header function is called', () => { - const options = getHeaderCenterNavbarOptions({ title: 'Test' }); + const options = getHeaderCompactStandardNavbarOptions({ title: 'Test' }); const headerElement = options.header(); @@ -32,8 +32,8 @@ describe('getHeaderCenterNavbarOptions', () => { }); describe('rendering', () => { - it('renders HeaderCenter with title', () => { - const options = getHeaderCenterNavbarOptions({ + it('renders HeaderCompactStandard with title', () => { + const options = getHeaderCompactStandardNavbarOptions({ title: 'Settings', titleProps: { testID: TITLE_TEST_ID }, }); @@ -43,9 +43,9 @@ describe('getHeaderCenterNavbarOptions', () => { expect(getByTestId(TITLE_TEST_ID)).toBeOnTheScreen(); }); - it('renders HeaderCenter with back button', () => { + it('renders HeaderCompactStandard with back button', () => { const onBack = jest.fn(); - const options = getHeaderCenterNavbarOptions({ + const options = getHeaderCompactStandardNavbarOptions({ title: 'Settings', onBack, backButtonProps: { testID: BACK_BUTTON_TEST_ID }, @@ -56,9 +56,9 @@ describe('getHeaderCenterNavbarOptions', () => { expect(getByTestId(BACK_BUTTON_TEST_ID)).toBeOnTheScreen(); }); - it('renders HeaderCenter with close button', () => { + it('renders HeaderCompactStandard with close button', () => { const onClose = jest.fn(); - const options = getHeaderCenterNavbarOptions({ + const options = getHeaderCompactStandardNavbarOptions({ title: 'Settings', onClose, closeButtonProps: { testID: CLOSE_BUTTON_TEST_ID }, @@ -71,9 +71,9 @@ describe('getHeaderCenterNavbarOptions', () => { }); describe('props forwarding', () => { - it('forwards onBack callback to HeaderCenter', () => { + it('forwards onBack callback to HeaderCompactStandard', () => { const onBack = jest.fn(); - const options = getHeaderCenterNavbarOptions({ + const options = getHeaderCompactStandardNavbarOptions({ title: 'Settings', onBack, backButtonProps: { testID: BACK_BUTTON_TEST_ID }, @@ -86,9 +86,9 @@ describe('getHeaderCenterNavbarOptions', () => { expect(onBack).toHaveBeenCalledTimes(1); }); - it('forwards onClose callback to HeaderCenter', () => { + it('forwards onClose callback to HeaderCompactStandard', () => { const onClose = jest.fn(); - const options = getHeaderCenterNavbarOptions({ + const options = getHeaderCompactStandardNavbarOptions({ title: 'Settings', onClose, closeButtonProps: { testID: CLOSE_BUTTON_TEST_ID }, @@ -101,8 +101,8 @@ describe('getHeaderCenterNavbarOptions', () => { expect(onClose).toHaveBeenCalledTimes(1); }); - it('forwards testID to HeaderCenter container', () => { - const options = getHeaderCenterNavbarOptions({ + it('forwards testID to HeaderCompactStandard container', () => { + const options = getHeaderCompactStandardNavbarOptions({ title: 'Settings', testID: 'custom-header', }); diff --git a/app/component-library/components-temp/HeaderCompactStandard/getHeaderCompactStandardNavbarOptions.tsx b/app/component-library/components-temp/HeaderCompactStandard/getHeaderCompactStandardNavbarOptions.tsx new file mode 100644 index 00000000000..6c69465f9eb --- /dev/null +++ b/app/component-library/components-temp/HeaderCompactStandard/getHeaderCompactStandardNavbarOptions.tsx @@ -0,0 +1,32 @@ +// Third party dependencies. +import React from 'react'; + +// Internal dependencies. +import HeaderCompactStandard from './HeaderCompactStandard'; +import { HeaderCompactStandardProps } from './HeaderCompactStandard.types'; + +/** + * Returns React Navigation screen options with a HeaderCompactStandard component. + * + * @example + * ```tsx + * const options = getHeaderCompactStandardNavbarOptions({ + * title: 'Settings', + * onBack: () => navigation.goBack(), + * onClose: () => navigation.pop(), + * includesTopInset: true, + * }); + * + * + * ``` + * + * @param options - Props to pass to the HeaderCompactStandard component. + * @returns React Navigation screen options object with header property. + */ +const getHeaderCompactStandardNavbarOptions = ( + options: HeaderCompactStandardProps, +): { header: () => React.ReactElement } => ({ + header: () => , +}); + +export default getHeaderCompactStandardNavbarOptions; diff --git a/app/component-library/components-temp/HeaderCompactStandard/index.ts b/app/component-library/components-temp/HeaderCompactStandard/index.ts new file mode 100644 index 00000000000..d09053c8a67 --- /dev/null +++ b/app/component-library/components-temp/HeaderCompactStandard/index.ts @@ -0,0 +1,3 @@ +export { default } from './HeaderCompactStandard'; +export { default as getHeaderCompactStandardNavbarOptions } from './getHeaderCompactStandardNavbarOptions'; +export type { HeaderCompactStandardProps } from './HeaderCompactStandard.types'; diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAddressRowsList/MultichainAddressRowsList.tsx b/app/component-library/components-temp/MultichainAccounts/MultichainAddressRowsList/MultichainAddressRowsList.tsx index 30d5fce4072..af52a655771 100644 --- a/app/component-library/components-temp/MultichainAccounts/MultichainAddressRowsList/MultichainAddressRowsList.tsx +++ b/app/component-library/components-temp/MultichainAccounts/MultichainAddressRowsList/MultichainAddressRowsList.tsx @@ -8,7 +8,6 @@ import { useStyles } from '../../../hooks'; import styleSheet from './MultichainAddressRowsList.styles'; import Text, { TextVariant, TextColor } from '../../../components/Texts/Text'; import TextFieldSearch from '../../../components/Form/TextFieldSearch'; -import { TextFieldSize } from '../../../components/Form/TextField/TextField.types'; import { strings } from '../../../../../locales/i18n'; import MultichainAddressRow, { SAMPLE_ICONS } from '../MultichainAddressRow'; import { selectEvmNetworkConfigurationsByChainId } from '../../../../selectors/networkController'; @@ -142,7 +141,6 @@ const MultichainAddressRowsList: React.FC = ({ )} value={searchPattern} onChangeText={handleSearchChange} - size={TextFieldSize.Lg} // @ts-expect-error - React Native style type mismatch due to outdated @types/react-native (v0.70.13) with RN v0.76.9 // See: https://github.com/MetaMask/metamask-mobile/pull/18956#discussion_r2316407382 style={styles.searchTextField} diff --git a/app/component-library/components-temp/TitleStandard/TitleStandard.stories.tsx b/app/component-library/components-temp/TitleStandard/TitleStandard.stories.tsx new file mode 100644 index 00000000000..df95159dd0d --- /dev/null +++ b/app/component-library/components-temp/TitleStandard/TitleStandard.stories.tsx @@ -0,0 +1,134 @@ +import React from 'react'; + +import { + Box, + BoxFlexDirection, + BoxAlignItems, + Text, + TextVariant, + Icon, + IconName, + IconSize, + IconColor, +} from '@metamask/design-system-react-native'; + +import TitleStandard from './TitleStandard'; + +const TitleStandardMeta = { + title: 'Components Temp / TitleStandard', + component: TitleStandard, + argTypes: { + title: { + control: 'text', + }, + topLabel: { + control: 'text', + }, + bottomLabel: { + control: 'text', + }, + }, +}; + +export default TitleStandardMeta; + +export const Default = { + args: { + topLabel: 'Send', + title: '$4.42', + }, +}; + +export const TitleOnly = { + render: () => , +}; + +export const WithTopLabel = { + render: () => , +}; + +export const WithBottomLabel = { + render: () => ( + + ), +}; + +export const WithTitleAccessory = { + render: () => ( + + + + } + /> + ), +}; + +export const WithTopAccessory = { + render: () => ( + + + Sending to + + } + title="0x1234...5678" + /> + ), +}; + +export const WithBottomAccessory = { + render: () => ( + + + ~$0.50 fee + + } + /> + ), +}; + +export const FullExample = { + render: () => ( + + + + } + bottomLabel="0.002 ETH" + /> + ), +}; + +export const NoEndAccessory = { + render: () => ( + + ), +}; diff --git a/app/component-library/components-temp/TitleStandard/TitleStandard.test.tsx b/app/component-library/components-temp/TitleStandard/TitleStandard.test.tsx new file mode 100644 index 00000000000..6c706efaded --- /dev/null +++ b/app/component-library/components-temp/TitleStandard/TitleStandard.test.tsx @@ -0,0 +1,158 @@ +// Third party dependencies. +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { Text } from 'react-native'; + +// Internal dependencies. +import TitleStandard from './TitleStandard'; + +const TEST_IDS = { + CONTAINER: 'title-standard-container', + TITLE: 'title-standard-title', + TOP_LABEL: 'title-standard-top-label', + BOTTOM_LABEL: 'title-standard-bottom-label', +}; + +describe('TitleStandard', () => { + describe('rendering', () => { + it('renders with title', () => { + const { getByText } = render(); + + expect(getByText('$4.42')).toBeOnTheScreen(); + }); + + it('renders container with testID when provided', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(TEST_IDS.CONTAINER)).toBeOnTheScreen(); + }); + + it('renders title with testID when provided via titleProps', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(TEST_IDS.TITLE)).toBeOnTheScreen(); + }); + }); + + describe('topLabel and topAccessory', () => { + it('renders topLabel', () => { + const { getByText } = render( + , + ); + + expect(getByText('Send')).toBeOnTheScreen(); + }); + + it('renders topLabel with testID when provided via topLabelProps', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(TEST_IDS.TOP_LABEL)).toBeOnTheScreen(); + }); + + it('renders topAccessory when no topLabel', () => { + const { getByText } = render( + Custom Top} />, + ); + + expect(getByText('Custom Top')).toBeOnTheScreen(); + }); + + it('topLabel takes priority over topAccessory', () => { + const { getByText, queryByText } = render( + Accessory} + />, + ); + + expect(getByText('Label Priority')).toBeOnTheScreen(); + expect(queryByText('Accessory')).toBeNull(); + }); + }); + + describe('bottomLabel and bottomAccessory', () => { + it('renders bottomLabel', () => { + const { getByText } = render( + , + ); + + expect(getByText('0.002 ETH')).toBeOnTheScreen(); + }); + + it('renders bottomLabel with testID when provided via bottomLabelProps', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(TEST_IDS.BOTTOM_LABEL)).toBeOnTheScreen(); + }); + + it('renders bottomAccessory when no bottomLabel', () => { + const { getByText } = render( + Custom Bottom} + />, + ); + + expect(getByText('Custom Bottom')).toBeOnTheScreen(); + }); + + it('bottomLabel takes priority over bottomAccessory', () => { + const { getByText, queryByText } = render( + Accessory} + />, + ); + + expect(getByText('Label Priority')).toBeOnTheScreen(); + expect(queryByText('Accessory')).toBeNull(); + }); + }); + + describe('titleAccessory', () => { + it('renders titleAccessory next to title', () => { + const { getByText } = render( + Info} />, + ); + + expect(getByText('$4.42')).toBeOnTheScreen(); + expect(getByText('Info')).toBeOnTheScreen(); + }); + }); + + describe('full component', () => { + it('renders all elements together', () => { + const { getByText } = render( + i} + bottomLabel="0.002 ETH" + />, + ); + + expect(getByText('Send')).toBeOnTheScreen(); + expect(getByText('$4.42')).toBeOnTheScreen(); + expect(getByText('i')).toBeOnTheScreen(); + expect(getByText('0.002 ETH')).toBeOnTheScreen(); + }); + }); +}); diff --git a/app/component-library/components-temp/TitleStandard/TitleStandard.tsx b/app/component-library/components-temp/TitleStandard/TitleStandard.tsx new file mode 100644 index 00000000000..c8ad834aaee --- /dev/null +++ b/app/component-library/components-temp/TitleStandard/TitleStandard.tsx @@ -0,0 +1,98 @@ +// Third party dependencies. +import React from 'react'; + +// External dependencies. +import { + Box, + BoxFlexDirection, + BoxAlignItems, + Text, + TextVariant, + TextColor, + FontWeight, +} from '@metamask/design-system-react-native'; + +// Internal dependencies. +import { TitleStandardProps } from './TitleStandard.types'; + +/** + * TitleStandard is a component that displays a title with optional accessories + * in a left-aligned layout. + * + * @example + * ```tsx + * } + * /> + * ``` + */ +const TitleStandard: React.FC = ({ + title, + titleAccessory, + topAccessory, + topLabel, + bottomAccessory, + bottomLabel, + titleProps, + topLabelProps, + bottomLabelProps, + testID, + twClassName, +}) => { + const hasTopContent = topAccessory || topLabel; + const hasBottomContent = bottomAccessory || bottomLabel; + + return ( + + {hasTopContent && ( + + {topLabel ? ( + + {topLabel} + + ) : ( + topAccessory + )} + + )} + + + {title && ( + + {title} + + )} + {titleAccessory} + + + {hasBottomContent && ( + + {bottomLabel ? ( + + {bottomLabel} + + ) : ( + bottomAccessory + )} + + )} + + ); +}; + +export default TitleStandard; diff --git a/app/component-library/components-temp/TitleStandard/TitleStandard.types.ts b/app/component-library/components-temp/TitleStandard/TitleStandard.types.ts new file mode 100644 index 00000000000..4bbb56046ed --- /dev/null +++ b/app/component-library/components-temp/TitleStandard/TitleStandard.types.ts @@ -0,0 +1,59 @@ +// Third party dependencies. +import { ReactNode } from 'react'; + +// External dependencies. +import { TextProps } from '@metamask/design-system-react-native'; + +/** + * TitleStandard component props. + */ +export interface TitleStandardProps { + /** + * Main title text, rendered with TextVariant.DisplayMd. + */ + title?: string; + /** + * Optional accessory rendered inline to the right of the title. + */ + titleAccessory?: ReactNode; + /** + * Optional accessory rendered in its own row above the title. + * If topLabel is provided, topLabel takes priority. + */ + topAccessory?: ReactNode; + /** + * Optional label rendered above the title with TextVariant.BodySMMedium + * and TextColor.Alternative. Takes priority over topAccessory. + */ + topLabel?: string; + /** + * Optional accessory rendered below the title. + * If bottomLabel is provided, bottomLabel takes priority. + */ + bottomAccessory?: ReactNode; + /** + * Optional label rendered below the title with TextVariant.BodySMMedium + * and TextColor.Alternative. Takes priority over bottomAccessory. + */ + bottomLabel?: string; + /** + * Optional props to pass to the title Text component. + */ + titleProps?: Partial; + /** + * Optional props to pass to the topLabel Text component. + */ + topLabelProps?: Partial; + /** + * Optional props to pass to the bottomLabel Text component. + */ + bottomLabelProps?: Partial; + /** + * Optional test ID for the component. + */ + testID?: string; + /** + * Optional Tailwind class name to apply to the container. + */ + twClassName?: string; +} diff --git a/app/component-library/components-temp/TitleStandard/index.ts b/app/component-library/components-temp/TitleStandard/index.ts new file mode 100644 index 00000000000..e884ec21e2b --- /dev/null +++ b/app/component-library/components-temp/TitleStandard/index.ts @@ -0,0 +1,2 @@ +export { default } from './TitleStandard'; +export type { TitleStandardProps } from './TitleStandard.types'; diff --git a/app/component-library/components-temp/TitleSubpage/TitleSubpage.stories.tsx b/app/component-library/components-temp/TitleSubpage/TitleSubpage.stories.tsx new file mode 100644 index 00000000000..962ee38b2dd --- /dev/null +++ b/app/component-library/components-temp/TitleSubpage/TitleSubpage.stories.tsx @@ -0,0 +1,120 @@ +import React from 'react'; + +import { + Box, + BoxFlexDirection, + BoxAlignItems, + Text, + TextVariant, + Icon, + IconName, + IconSize, + IconColor, +} from '@metamask/design-system-react-native'; + +import { AvatarSize } from '../../components/Avatars/Avatar/Avatar.types'; +import AvatarToken from '../../components/Avatars/Avatar/variants/AvatarToken'; +import { SAMPLE_AVATARTOKEN_PROPS } from '../../components/Avatars/Avatar/variants/AvatarToken/AvatarToken.constants'; + +import TitleSubpage from './TitleSubpage'; + +const TitleSubpageMeta = { + title: 'Components Temp / TitleSubpage', + component: TitleSubpage, + argTypes: { + title: { + control: 'text', + }, + bottomLabel: { + control: 'text', + }, + }, +}; + +export default TitleSubpageMeta; + +export const Default = { + args: { + title: 'Token Name', + bottomLabel: '$1,234.56', + }, +}; + +export const TitleOnly = { + render: () => , +}; + +export const WithBottomLabel = { + render: () => , +}; + +export const WithStartAccessory = { + render: () => ( + + } + title="Wrapped Ethereum" + bottomLabel="$3,456.78" + /> + ), +}; + +export const WithTitleAccessory = { + render: () => ( + + + + } + bottomLabel="$1,234.56" + /> + ), +}; + +export const WithBottomAccessory = { + render: () => ( + + + ~$0.50 fee + + } + /> + ), +}; + +export const FullExample = { + render: () => ( + + } + title="Wrapped Ethereum" + titleAccessory={ + + + + } + bottomLabel="$3,456.78" + /> + ), +}; + +export const NoStartAccessory = { + render: () => ( + + ), +}; diff --git a/app/component-library/components-temp/TitleSubpage/TitleSubpage.test.tsx b/app/component-library/components-temp/TitleSubpage/TitleSubpage.test.tsx new file mode 100644 index 00000000000..804ade8ddb4 --- /dev/null +++ b/app/component-library/components-temp/TitleSubpage/TitleSubpage.test.tsx @@ -0,0 +1,142 @@ +// Third party dependencies. +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { Text } from 'react-native'; + +// Internal dependencies. +import TitleSubpage from './TitleSubpage'; + +const TEST_IDS = { + CONTAINER: 'title-subpage-container', + TITLE: 'title-subpage-title', + BOTTOM_LABEL: 'title-subpage-bottom-label', +}; + +describe('TitleSubpage', () => { + describe('rendering', () => { + it('renders with title', () => { + const { getByText } = render(); + + expect(getByText('Token Name')).toBeOnTheScreen(); + }); + + it('renders container with testID when provided', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(TEST_IDS.CONTAINER)).toBeOnTheScreen(); + }); + + it('renders title with testID when provided via titleProps', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(TEST_IDS.TITLE)).toBeOnTheScreen(); + }); + }); + + describe('startAccessory', () => { + it('renders startAccessory when provided', () => { + const { getByText } = render( + Avatar} + />, + ); + + expect(getByText('Avatar')).toBeOnTheScreen(); + }); + + it('renders startAccessory alongside title', () => { + const { getByText } = render( + Avatar} + />, + ); + + expect(getByText('Avatar')).toBeOnTheScreen(); + expect(getByText('Token Name')).toBeOnTheScreen(); + }); + }); + + describe('bottomLabel and bottomAccessory', () => { + it('renders bottomLabel', () => { + const { getByText } = render( + , + ); + + expect(getByText('$1,234.56')).toBeOnTheScreen(); + }); + + it('renders bottomLabel with testID when provided via bottomLabelProps', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(TEST_IDS.BOTTOM_LABEL)).toBeOnTheScreen(); + }); + + it('renders bottomAccessory when no bottomLabel', () => { + const { getByText } = render( + Custom Bottom} + />, + ); + + expect(getByText('Custom Bottom')).toBeOnTheScreen(); + }); + + it('bottomLabel takes priority over bottomAccessory', () => { + const { getByText, queryByText } = render( + Accessory} + />, + ); + + expect(getByText('Label Priority')).toBeOnTheScreen(); + expect(queryByText('Accessory')).toBeNull(); + }); + }); + + describe('titleAccessory', () => { + it('renders titleAccessory next to title', () => { + const { getByText } = render( + Info} />, + ); + + expect(getByText('Token Name')).toBeOnTheScreen(); + expect(getByText('Info')).toBeOnTheScreen(); + }); + }); + + describe('full component', () => { + it('renders all elements together', () => { + const { getByText } = render( + Avatar} + title="Token Name" + titleAccessory={i} + bottomLabel="$1,234.56" + />, + ); + + expect(getByText('Avatar')).toBeOnTheScreen(); + expect(getByText('Token Name')).toBeOnTheScreen(); + expect(getByText('i')).toBeOnTheScreen(); + expect(getByText('$1,234.56')).toBeOnTheScreen(); + }); + }); +}); diff --git a/app/component-library/components-temp/TitleSubpage/TitleSubpage.tsx b/app/component-library/components-temp/TitleSubpage/TitleSubpage.tsx new file mode 100644 index 00000000000..dfda21374bd --- /dev/null +++ b/app/component-library/components-temp/TitleSubpage/TitleSubpage.tsx @@ -0,0 +1,84 @@ +// Third party dependencies. +import React from 'react'; + +// External dependencies. +import { + Box, + BoxFlexDirection, + BoxAlignItems, + Text, + TextVariant, + TextColor, + FontWeight, +} from '@metamask/design-system-react-native'; + +// Internal dependencies. +import { TitleSubpageProps } from './TitleSubpage.types'; + +/** + * TitleSubpage is a component that displays a title with optional accessories + * in a left-aligned layout, with an optional startAccessory to the left. + * + * @example + * ```tsx + * } + * title="Token Name" + * bottomLabel="$1,234.56" + * /> + * ``` + */ +const TitleSubpage: React.FC = ({ + title, + titleAccessory, + startAccessory, + bottomAccessory, + bottomLabel, + titleProps, + bottomLabelProps, + testID, + twClassName, +}) => ( + + {startAccessory} + + + + {title && ( + + {title} + + )} + {titleAccessory} + + + {(bottomAccessory || bottomLabel) && ( + + {bottomLabel ? ( + + {bottomLabel} + + ) : ( + bottomAccessory + )} + + )} + + +); + +export default TitleSubpage; diff --git a/app/component-library/components-temp/TitleSubpage/TitleSubpage.types.ts b/app/component-library/components-temp/TitleSubpage/TitleSubpage.types.ts new file mode 100644 index 00000000000..d43117db8aa --- /dev/null +++ b/app/component-library/components-temp/TitleSubpage/TitleSubpage.types.ts @@ -0,0 +1,50 @@ +// Third party dependencies. +import { ReactNode } from 'react'; + +// External dependencies. +import { TextProps } from '@metamask/design-system-react-native'; + +/** + * TitleSubpage component props. + */ +export interface TitleSubpageProps { + /** + * Main title text, rendered with TextVariant.HeadingMd. + */ + title?: string; + /** + * Optional accessory rendered inline to the right of the title. + */ + titleAccessory?: ReactNode; + /** + * Optional accessory rendered to the left of the title and bottom content. + * Vertically centered with a gap of 12px from the content. + */ + startAccessory?: ReactNode; + /** + * Optional accessory rendered below the title. + * If bottomLabel is provided, bottomLabel takes priority. + */ + bottomAccessory?: ReactNode; + /** + * Optional label rendered below the title with TextVariant.BodySm + * and TextColor.Alternative. Takes priority over bottomAccessory. + */ + bottomLabel?: string; + /** + * Optional props to pass to the title Text component. + */ + titleProps?: Partial; + /** + * Optional props to pass to the bottomLabel Text component. + */ + bottomLabelProps?: Partial; + /** + * Optional test ID for the component. + */ + testID?: string; + /** + * Optional Tailwind class name to apply to the container. + */ + twClassName?: string; +} diff --git a/app/component-library/components-temp/TitleSubpage/index.ts b/app/component-library/components-temp/TitleSubpage/index.ts new file mode 100644 index 00000000000..ebfff1d2aa5 --- /dev/null +++ b/app/component-library/components-temp/TitleSubpage/index.ts @@ -0,0 +1,2 @@ +export { default } from './TitleSubpage'; +export type { TitleSubpageProps } from './TitleSubpage.types'; diff --git a/app/component-library/components/Form/TextField/README.md b/app/component-library/components/Form/TextField/README.md index 0e31d549218..7a9d197b50f 100644 --- a/app/component-library/components/Form/TextField/README.md +++ b/app/component-library/components/Form/TextField/README.md @@ -8,14 +8,6 @@ TextField is an input component that lets user enter a text data into a boxed fi This component extends [Input](./foundation/Input/Input.tsx) component. -### `size` - -Optional prop for size of the TextField. - -| TYPE | REQUIRED | DEFAULT | -| :-------------------------------------------------- | :------------------------------------------------------ | :----------------------------------------------------- | -| [TextFieldSize](./TextField.types.ts) | No | TextFieldSize.Md | - ### `startAccessory` Optional content to display before the Input. @@ -88,7 +80,6 @@ Optional boolean to disable state styles. , - endAccessory: ( - { - // eslint-disable-next-line no-console - console.log('pressed'); - }} - > - SAMPLE - - ), - size: DEFAULT_TEXTFIELD_SIZE, - isError: false, - isDisabled: false, - isReadonly: false, - placeholder: 'Sample Placeholder', -}; diff --git a/app/component-library/components/Form/TextField/TextField.stories.tsx b/app/component-library/components/Form/TextField/TextField.stories.tsx index e0c0aa0fedd..72a78c70fa8 100644 --- a/app/component-library/components/Form/TextField/TextField.stories.tsx +++ b/app/component-library/components/Form/TextField/TextField.stories.tsx @@ -1,51 +1,93 @@ -/* eslint-disable react/display-name */ import React from 'react'; +import { View } from 'react-native'; -// Internal dependencies. -import { default as TextFieldComponent } from './TextField'; -import { SAMPLE_TEXTFIELD_PROPS } from './TextField.constants'; -import { TextFieldProps, TextFieldSize } from './TextField.types'; +import Icon, { IconName, IconSize } from '../../Icons/Icon'; + +import TextField from './TextField'; const TextFieldMeta = { - title: 'Component Library / Form', - component: TextFieldComponent, + title: 'Component Library / Form / TextField', + component: TextField, argTypes: { - size: { - options: TextFieldSize, - control: { - type: 'select', - }, - defaultValue: SAMPLE_TEXTFIELD_PROPS.size, - }, isError: { - control: { type: 'boolean' }, - defaultValue: SAMPLE_TEXTFIELD_PROPS.isError, + control: 'boolean', }, isDisabled: { - control: { type: 'boolean' }, - defaultValue: SAMPLE_TEXTFIELD_PROPS.isDisabled, + control: 'boolean', }, isReadonly: { - control: { type: 'boolean' }, - defaultValue: SAMPLE_TEXTFIELD_PROPS.isReadonly, + control: 'boolean', }, placeholder: { - control: { type: 'text' }, - defaultValue: SAMPLE_TEXTFIELD_PROPS.placeholder, + control: 'text', }, }, }; + export default TextFieldMeta; -export const TextField = { - render: ( - args: JSX.IntrinsicAttributes & - TextFieldProps & { children?: React.ReactNode }, - ) => ( - ( + } + /> + ), +}; + +export const WithEndAccessory = { + render: () => ( + + + + } + /> + ), +}; + +export const WithBothAccessories = { + render: () => ( + } + endAccessory={} /> ), }; diff --git a/app/component-library/components/Form/TextField/TextField.styles.ts b/app/component-library/components/Form/TextField/TextField.styles.ts index aa36ab72d69..2a07897a1ec 100644 --- a/app/component-library/components/Form/TextField/TextField.styles.ts +++ b/app/component-library/components/Form/TextField/TextField.styles.ts @@ -22,13 +22,13 @@ const styleSheet = (params: { vars: TextFieldStyleSheetVars; }) => { const { theme, vars } = params; - const { style, size, isError, isDisabled, isFocused } = vars; - let borderColor = theme.colors.border.default; + const { style, isError, isDisabled, isFocused } = vars; + let borderColor = theme.colors.border.muted; if (isError) { borderColor = theme.colors.error.default; } if (isFocused) { - borderColor = theme.colors.primary.default; + borderColor = theme.colors.border.default; } return StyleSheet.create({ @@ -36,18 +36,18 @@ const styleSheet = (params: { { flexDirection: 'row', alignItems: 'center', - borderRadius: 8, - height: Number(size), + borderRadius: 12, + height: 48, borderWidth: BORDER_WIDTH, borderColor, paddingHorizontal: 16, opacity: isDisabled ? 0.5 : 1, - backgroundColor: theme.colors.background.default, + backgroundColor: theme.colors.background.muted, }, StyleSheet.flatten(style), ) as ViewStyle, startAccessory: { - marginRight: 8, + marginRight: 12, }, inputContainer: { flex: 1, @@ -57,10 +57,10 @@ const styleSheet = (params: { input: { backgroundColor: 'inherit', // subtract border width from height so it won't overflow the container - height: Number(size) - BORDER_WIDTH * 2, + height: 48 - BORDER_WIDTH * 2, }, endAccessory: { - marginLeft: 8, + marginLeft: 12, }, }); }; diff --git a/app/component-library/components/Form/TextField/TextField.test.tsx b/app/component-library/components/Form/TextField/TextField.test.tsx index b06e2ec543b..82be9e6fc72 100644 --- a/app/component-library/components/Form/TextField/TextField.test.tsx +++ b/app/component-library/components/Form/TextField/TextField.test.tsx @@ -10,40 +10,49 @@ import { TEXTFIELD_STARTACCESSORY_TEST_ID, TEXTFIELD_ENDACCESSORY_TEST_ID, } from './TextField.constants'; -import { TextFieldSize } from './TextField.types'; describe('TextField', () => { - it('should render default settings correctly', () => { + it('renders default settings correctly', () => { const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); }); - it('should render TextField', () => { + + it('renders TextField component', () => { const wrapper = shallow(); + const textFieldComponent = wrapper.findWhere( (node) => node.prop('testID') === TEXTFIELD_TEST_ID, ); + expect(textFieldComponent.exists()).toBe(true); }); - it('should render the given size', () => { - const testSize = TextFieldSize.Lg; - const wrapper = shallow(); - const textFieldComponent = wrapper.findWhere( - (node) => node.prop('testID') === TEXTFIELD_TEST_ID, - ); - expect(textFieldComponent.props().style.height).toBe(Number(testSize)); - }); - it('should render the startAccessory if given', () => { + + it('renders startAccessory when provided', () => { const wrapper = shallow(} />); + const textFieldComponent = wrapper.findWhere( (node) => node.prop('testID') === TEXTFIELD_STARTACCESSORY_TEST_ID, ); + expect(textFieldComponent.exists()).toBe(true); }); - it('should render the endAccessory if given', () => { + + it('renders endAccessory when provided', () => { const wrapper = shallow(} />); + const textFieldComponent = wrapper.findWhere( (node) => node.prop('testID') === TEXTFIELD_ENDACCESSORY_TEST_ID, ); + expect(textFieldComponent.exists()).toBe(true); }); + + it('renders as single line by default', () => { + const wrapper = shallow(); + + const inputComponent = wrapper.find('ForwardRef'); + + expect(inputComponent.prop('numberOfLines')).toBe(1); + }); }); diff --git a/app/component-library/components/Form/TextField/TextField.tsx b/app/component-library/components/Form/TextField/TextField.tsx index 47260b7a1cd..3a1a553d555 100644 --- a/app/component-library/components/Form/TextField/TextField.tsx +++ b/app/component-library/components/Form/TextField/TextField.tsx @@ -12,13 +12,12 @@ import { Pressable, TextInput, View } from 'react-native'; // External dependencies. import { useStyles } from '../../../hooks'; import Input from './foundation/Input'; +import { TextVariant } from '../../../components/Texts/Text'; // Internal dependencies. import styleSheet from './TextField.styles'; import { TextFieldProps } from './TextField.types'; import { - DEFAULT_TEXTFIELD_SIZE, - TOKEN_TEXTFIELD_INPUT_TEXT_VARIANT, TEXTFIELD_TEST_ID, TEXTFIELD_STARTACCESSORY_TEST_ID, TEXTFIELD_ENDACCESSORY_TEST_ID, @@ -28,7 +27,6 @@ const TextField = React.forwardRef( ( { style, - size = DEFAULT_TEXTFIELD_SIZE, startAccessory, endAccessory, isError = false, @@ -54,7 +52,6 @@ const TextField = React.forwardRef( const { styles } = useStyles(styleSheet, { style, - size, isError, isDisabled, isFocused, @@ -107,13 +104,15 @@ const TextField = React.forwardRef( {inputElement ?? ( { - /** - * Optional prop for size of the TextField. - * @default TextFieldSize.Md - */ - size?: TextFieldSize; /** * Optional content to display before the Input. */ @@ -48,7 +34,7 @@ export interface TextFieldProps */ export type TextFieldStyleSheetVars = Pick< TextFieldProps, - 'style' | 'size' | 'isError' | 'isDisabled' + 'style' | 'isError' | 'isDisabled' > & { isFocused: boolean; }; diff --git a/app/component-library/components/Form/TextField/__snapshots__/TextField.test.tsx.snap b/app/component-library/components/Form/TextField/__snapshots__/TextField.test.tsx.snap index 6c0d6804cd5..faedc23f031 100644 --- a/app/component-library/components/Form/TextField/__snapshots__/TextField.test.tsx.snap +++ b/app/component-library/components/Form/TextField/__snapshots__/TextField.test.tsx.snap @@ -1,17 +1,17 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`TextField should render default settings correctly 1`] = ` +exports[`TextField renders default settings correctly 1`] = ` { - console.log('clicked'); - }, - size: DEFAULT_TEXTFIELD_SIZE, - isError: false, - isDisabled: false, - isReadonly: false, - placeholder: 'Sample placeholder', -}; diff --git a/app/component-library/components/Form/TextFieldSearch/TextFieldSearch.stories.tsx b/app/component-library/components/Form/TextFieldSearch/TextFieldSearch.stories.tsx index 74152135e1c..f3b44be2808 100644 --- a/app/component-library/components/Form/TextFieldSearch/TextFieldSearch.stories.tsx +++ b/app/component-library/components/Form/TextFieldSearch/TextFieldSearch.stories.tsx @@ -1,44 +1,66 @@ -/* eslint-disable react/display-name */ -// External dependencies. -import { TextFieldSize } from '../TextField/TextField.types'; +/* eslint-disable no-console */ +import React from 'react'; -// Internal dependencies. -import { default as TextFieldSearchComponent } from './TextFieldSearch'; -import { SAMPLE_TEXTFIELDSEARCH_PROPS } from './TextFieldSearch.constants'; +import TextFieldSearch from './TextFieldSearch'; const TextFieldSearchMeta = { - title: 'Component Library / Form', - component: TextFieldSearchComponent, + title: 'Component Library / Form / TextFieldSearch', + component: TextFieldSearch, argTypes: { - size: { - options: TextFieldSize, - control: { - type: 'select', - }, - defaultValue: SAMPLE_TEXTFIELDSEARCH_PROPS.size, - }, isError: { - control: { type: 'boolean' }, - defaultValue: SAMPLE_TEXTFIELDSEARCH_PROPS.isError, + control: 'boolean', }, isDisabled: { - control: { type: 'boolean' }, - defaultValue: SAMPLE_TEXTFIELDSEARCH_PROPS.isDisabled, + control: 'boolean', }, isReadonly: { - control: { type: 'boolean' }, - defaultValue: SAMPLE_TEXTFIELDSEARCH_PROPS.isReadonly, + control: 'boolean', }, placeholder: { - control: { type: 'text' }, - defaultValue: SAMPLE_TEXTFIELDSEARCH_PROPS.placeholder, + control: 'text', }, showClearButton: { - control: { type: 'boolean' }, - defaultValue: SAMPLE_TEXTFIELDSEARCH_PROPS.showClearButton, + control: 'boolean', }, }, }; + export default TextFieldSearchMeta; -export const TextFieldSearch = {}; +export const Default = { + args: { + placeholder: 'Search', + }, +}; + +export const WithClearButton = { + render: () => ( + console.log('Clear pressed')} + /> + ), +}; + +export const ErrorState = { + args: { + placeholder: 'Search', + isError: true, + }, +}; + +export const Disabled = { + args: { + placeholder: 'Search disabled', + isDisabled: true, + }, +}; + +export const ReadonlyState = { + args: { + placeholder: 'Search readonly', + value: 'Search query', + isReadonly: true, + }, +}; diff --git a/app/component-library/components/Form/TextFieldSearch/TextFieldSearch.styles.ts b/app/component-library/components/Form/TextFieldSearch/TextFieldSearch.styles.ts new file mode 100644 index 00000000000..946232310c3 --- /dev/null +++ b/app/component-library/components/Form/TextFieldSearch/TextFieldSearch.styles.ts @@ -0,0 +1,10 @@ +// Third party dependencies. +import { StyleSheet } from 'react-native'; + +const styleSheet = StyleSheet.create({ + base: { + borderRadius: 24, + }, +}); + +export default styleSheet; diff --git a/app/component-library/components/Form/TextFieldSearch/TextFieldSearch.test.tsx b/app/component-library/components/Form/TextFieldSearch/TextFieldSearch.test.tsx index 868610007d1..e97e1e00328 100644 --- a/app/component-library/components/Form/TextFieldSearch/TextFieldSearch.test.tsx +++ b/app/component-library/components/Form/TextFieldSearch/TextFieldSearch.test.tsx @@ -5,17 +5,56 @@ import { shallow } from 'enzyme'; // Internal dependencies. import TextFieldSearch from './TextFieldSearch'; import { TEXTFIELDSEARCH_TEST_ID } from './TextFieldSearch.constants'; +import styles from './TextFieldSearch.styles'; describe('TextFieldSearch', () => { - it('should render default settings correctly', () => { + it('renders default settings correctly', () => { const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); }); - it('should render TextFieldSearch', () => { + + it('renders TextFieldSearch component', () => { const wrapper = shallow(); + const textFieldSearchComponent = wrapper.findWhere( (node) => node.prop('testID') === TEXTFIELDSEARCH_TEST_ID, ); + expect(textFieldSearchComponent.exists()).toBe(true); }); + + it('applies rounded border radius style', () => { + const wrapper = shallow(); + + const textFieldComponent = wrapper.findWhere( + (node) => node.prop('testID') === TEXTFIELDSEARCH_TEST_ID, + ); + const styleArray = textFieldComponent.prop('style'); + + expect(styleArray).toContainEqual(styles.base); + }); + + it('renders clear button when showClearButton is true', () => { + const mockOnPress = jest.fn(); + const wrapper = shallow( + , + ); + + const textFieldComponent = wrapper.findWhere( + (node) => node.prop('testID') === TEXTFIELDSEARCH_TEST_ID, + ); + + expect(textFieldComponent.prop('endAccessory')).not.toBe(false); + }); + + it('hides clear button by default', () => { + const wrapper = shallow(); + + const textFieldComponent = wrapper.findWhere( + (node) => node.prop('testID') === TEXTFIELDSEARCH_TEST_ID, + ); + + expect(textFieldComponent.prop('endAccessory')).toBe(false); + }); }); diff --git a/app/component-library/components/Form/TextFieldSearch/TextFieldSearch.tsx b/app/component-library/components/Form/TextFieldSearch/TextFieldSearch.tsx index 6bea6984e61..377c8b8c2f2 100644 --- a/app/component-library/components/Form/TextFieldSearch/TextFieldSearch.tsx +++ b/app/component-library/components/Form/TextFieldSearch/TextFieldSearch.tsx @@ -4,31 +4,28 @@ import React, { useCallback } from 'react'; // External dependencies. -import ButtonIcon from '../../Buttons/ButtonIcon'; -import Icon from '../../Icons/Icon'; +import ButtonIcon, { ButtonIconSizes } from '../../Buttons/ButtonIcon'; +import Icon, { IconName, IconSize, IconColor } from '../../Icons/Icon'; import TextField from '../TextField/TextField'; // Internal dependencies. import { TextFieldSearchProps } from './TextFieldSearch.types'; -import { - TEXTFIELDSEARCH_TEST_ID, - DEFAULT_TEXTFIELDSEARCH_SEARCHICON_NAME, - DEFAULT_TEXTFIELDSEARCH_SEARCHICON_SIZE, - DEFAULT_TEXTFIELDSEARCH_CLOSEICON_NAME, - DEFAULT_TEXTFIELDSEARCH_CLOSEICON_SIZE, -} from './TextFieldSearch.constants'; +import { TEXTFIELDSEARCH_TEST_ID } from './TextFieldSearch.constants'; +import styles from './TextFieldSearch.styles'; const TextFieldSearch: React.FC = ({ showClearButton = false, onPressClearButton, clearButtonProps, value, + style, ...props }) => { const searchIcon = ( ); @@ -38,8 +35,9 @@ const TextFieldSearch: React.FC = ({ const clearButton = ( @@ -50,6 +48,7 @@ const TextFieldSearch: React.FC = ({ startAccessory={searchIcon} endAccessory={showClearButton && clearButton} testID={TEXTFIELDSEARCH_TEST_ID} + style={[style, styles.base]} {...props} /> ); diff --git a/app/component-library/components/Form/TextFieldSearch/__snapshots__/TextFieldSearch.test.tsx.snap b/app/component-library/components/Form/TextFieldSearch/__snapshots__/TextFieldSearch.test.tsx.snap index 130ad728b8d..048c6c56106 100644 --- a/app/component-library/components/Form/TextFieldSearch/__snapshots__/TextFieldSearch.test.tsx.snap +++ b/app/component-library/components/Form/TextFieldSearch/__snapshots__/TextFieldSearch.test.tsx.snap @@ -1,14 +1,23 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`TextFieldSearch should render default settings correctly 1`] = ` +exports[`TextFieldSearch renders default settings correctly 1`] = ` } + style={ + [ + undefined, + { + "borderRadius": 24, + }, + ] + } testID="textfieldsearch" /> `; diff --git a/app/components/Approvals/AddChainApproval/AddChainApproval.styles.ts b/app/components/Approvals/AddChainApproval/AddChainApproval.styles.ts deleted file mode 100644 index c28377935fe..00000000000 --- a/app/components/Approvals/AddChainApproval/AddChainApproval.styles.ts +++ /dev/null @@ -1,17 +0,0 @@ -// Third party dependencies. -import { StyleSheet } from 'react-native'; - -/** - * Style sheet function for WalletActions component. - * - * @returns StyleSheet object. - */ -const styleSheet = () => - StyleSheet.create({ - actionsContainer: { - alignItems: 'flex-start', - justifyContent: 'center', - }, - }); - -export default styleSheet; diff --git a/app/components/Approvals/AddChainApproval/AddChainApproval.tsx b/app/components/Approvals/AddChainApproval/AddChainApproval.tsx index 83706c8d984..9a9fc56f1df 100644 --- a/app/components/Approvals/AddChainApproval/AddChainApproval.tsx +++ b/app/components/Approvals/AddChainApproval/AddChainApproval.tsx @@ -3,15 +3,9 @@ import useApprovalRequest from '../../Views/confirmations/hooks/useApprovalReque import { ApprovalTypes } from '../../../core/RPCMethods/RPCMethodMiddleware'; import NetworkVerificationInfo from '../../UI/NetworkVerificationInfo'; import BottomSheet from '../../../component-library/components/BottomSheets/BottomSheet'; -import { useStyles } from '../../../component-library/hooks'; -import { View } from 'react-native'; - -// Internal dependencies -import styleSheet from './AddChainApproval.styles'; const AddChainApproval = () => { const { approvalRequest, onConfirm, onReject } = useApprovalRequest(); - const { styles } = useStyles(styleSheet, {}); if (approvalRequest?.type !== ApprovalTypes.ADD_ETHEREUM_CHAIN) return null; @@ -20,14 +14,12 @@ const AddChainApproval = () => { return ( onReject()} shouldNavigateBack={false}> - - - + ); }; diff --git a/app/components/Approvals/AddChainApproval/__snapshots__/AddChainApproval.test.tsx.snap b/app/components/Approvals/AddChainApproval/__snapshots__/AddChainApproval.test.tsx.snap index 9f65a4eb161..75d05c64ae5 100644 --- a/app/components/Approvals/AddChainApproval/__snapshots__/AddChainApproval.test.tsx.snap +++ b/app/components/Approvals/AddChainApproval/__snapshots__/AddChainApproval.test.tsx.snap @@ -5,18 +5,9 @@ exports[`AddChainApproval renders 1`] = ` onClose={[Function]} shouldNavigateBack={false} > - - - + `; diff --git a/app/components/Nav/App/App.tsx b/app/components/Nav/App/App.tsx index e56cbdafcfc..0b053043028 100644 --- a/app/components/Nav/App/App.tsx +++ b/app/components/Nav/App/App.tsx @@ -68,6 +68,7 @@ import AccountActions from '../../../components/Views/AccountActions'; import FiatOnTestnetsFriction from '../../../components/Views/Settings/AdvancedSettings/FiatOnTestnetsFriction'; import WalletActions from '../../Views/WalletActions'; import FundActionMenu from '../../UI/FundActionMenu'; +import ClaimOnLineaBottomSheet from '../../UI/Earn/components/MerklRewards/ClaimOnLineaBottomSheet'; import MoreTokenActionsMenu from '../../UI/TokenDetails/components/MoreTokenActionsMenu'; import NetworkSelector from '../../../components/Views/NetworkSelector'; import ReturnToAppNotification from '../../Views/ReturnToAppNotification'; @@ -369,6 +370,10 @@ const RootModalFlow = (props: RootModalFlowProps) => ( name={Routes.MODAL.FUND_ACTION_MENU} component={FundActionMenu} /> + ( component={AssetDetails} initialParams={{ address: props.route.params?.address }} /> + ); @@ -944,7 +949,7 @@ const MainNavigator = () => { name="AddAsset" component={AddAsset} options={({ route, navigation }) => ({ - ...getHeaderCenterNavbarOptions({ + ...getHeaderCompactStandardNavbarOptions({ title: strings( `add_asset.${route.params?.assetType === TOKEN ? TOKEN_TITLE : NFT_TITLE}`, ), diff --git a/app/components/Snaps/SnapUIAddressInput/SnapUIAddressInput.tsx b/app/components/Snaps/SnapUIAddressInput/SnapUIAddressInput.tsx index 031cec3795a..e2b63bae6e6 100644 --- a/app/components/Snaps/SnapUIAddressInput/SnapUIAddressInput.tsx +++ b/app/components/Snaps/SnapUIAddressInput/SnapUIAddressInput.tsx @@ -13,9 +13,7 @@ import Text, { TextVariant, } from '../../../component-library/components/Texts/Text'; import Label from '../../../component-library/components/Form/Label'; -import TextField, { - TextFieldSize, -} from '../../../component-library/components/Form/TextField'; +import TextField from '../../../component-library/components/Form/TextField'; import HelpText, { HelpTextSeverity, } from '../../../component-library/components/Form/HelpText'; @@ -233,7 +231,6 @@ export const SnapUIAddressInput = ({ {label && } {label}} {label}} ({ })); describe('SnapUIInput', () => { - const clearBorderColor = '#b7bbc8'; - const focusedBorderColor = '#4459ff'; + const clearBorderColor = '#b7bbc866'; + const focusedBorderColor = '#b7bbc8'; beforeEach(() => { jest.resetAllMocks(); diff --git a/app/components/UI/Bridge/components/BridgeTokenSelector/BridgeTokenSelector.test.tsx b/app/components/UI/Bridge/components/BridgeTokenSelector/BridgeTokenSelector.test.tsx index 117e174416e..19853309561 100644 --- a/app/components/UI/Bridge/components/BridgeTokenSelector/BridgeTokenSelector.test.tsx +++ b/app/components/UI/Bridge/components/BridgeTokenSelector/BridgeTokenSelector.test.tsx @@ -163,9 +163,9 @@ jest.mock('../../../../../../locales/i18n', () => ({ strings: (key: string) => key, })); jest.mock( - '../../../../../component-library/components-temp/HeaderCenter', + '../../../../../component-library/components-temp/HeaderCompactStandard', () => ({ - getHeaderCenterNavbarOptions: jest.fn(() => ({})), + getHeaderCompactStandardNavbarOptions: jest.fn(() => ({})), }), ); diff --git a/app/components/UI/Bridge/components/BridgeTokenSelector/BridgeTokenSelector.tsx b/app/components/UI/Bridge/components/BridgeTokenSelector/BridgeTokenSelector.tsx index ecc4c1a6427..b82334eeb66 100644 --- a/app/components/UI/Bridge/components/BridgeTokenSelector/BridgeTokenSelector.tsx +++ b/app/components/UI/Bridge/components/BridgeTokenSelector/BridgeTokenSelector.tsx @@ -14,7 +14,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { useSelector, useDispatch } from 'react-redux'; import { strings } from '../../../../../../locales/i18n'; -import { getHeaderCenterNavbarOptions } from '../../../../../component-library/components-temp/HeaderCenter'; +import { getHeaderCompactStandardNavbarOptions } from '../../../../../component-library/components-temp/HeaderCompactStandard'; import { FlatList } from 'react-native-gesture-handler'; import { NetworkPills } from './NetworkPills'; import { CaipChainId } from '@metamask/utils'; @@ -106,7 +106,7 @@ export const BridgeTokenSelector: React.FC = () => { // Set navigation options for header useEffect(() => { navigation.setOptions( - getHeaderCenterNavbarOptions({ + getHeaderCompactStandardNavbarOptions({ title: strings('bridge.select_token'), onBack: () => navigation.goBack(), includesTopInset: true, diff --git a/app/components/UI/Bridge/components/BridgeTokenSelectorBase.tsx b/app/components/UI/Bridge/components/BridgeTokenSelectorBase.tsx index 041f5bc91e9..8e622c43dad 100644 --- a/app/components/UI/Bridge/components/BridgeTokenSelectorBase.tsx +++ b/app/components/UI/Bridge/components/BridgeTokenSelectorBase.tsx @@ -6,7 +6,7 @@ import Text, { } from '../../../../component-library/components/Texts/Text'; import { useStyles } from '../../../../component-library/hooks'; import { Theme } from '../../../../util/theme/models'; -import HeaderCenter from '../../../../component-library/components-temp/HeaderCenter'; +import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; import { strings } from '../../../../../locales/i18n'; import { FlexDirection, AlignItems } from '../../Box/box.types'; import { useTokenSearch } from '../hooks/useTokenSearch'; @@ -226,7 +226,7 @@ export const BridgeTokenSelectorBase: React.FC = isFullscreen keyboardAvoidingViewEnabled={false} > - { return ( - diff --git a/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.test.tsx b/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.test.tsx index 99e00e9f1af..a054df0a5e6 100644 --- a/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.test.tsx +++ b/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.test.tsx @@ -21,9 +21,9 @@ jest.mock( }, ); -// Mock HeaderCenter +// Mock HeaderCompactStandard jest.mock( - '../../../../../component-library/components-temp/HeaderCenter', + '../../../../../component-library/components-temp/HeaderCompactStandard', () => { const ReactNative = jest.requireActual('react-native'); const { View, Text, TouchableOpacity } = ReactNative; diff --git a/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.tsx b/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.tsx index fa21c40c565..b94daaa162a 100644 --- a/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.tsx +++ b/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useRef, useState } from 'react'; import BottomSheet, { BottomSheetRef, } from '../../../../../component-library/components/BottomSheets/BottomSheet'; -import HeaderCenter from '../../../../../component-library/components-temp/HeaderCenter'; +import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; import { strings } from '../../../../../../locales/i18n'; import { View } from 'react-native'; import { @@ -117,7 +117,10 @@ export const CustomSlippageModal = () => { return ( - + { const ReactNative = jest.requireActual('react-native'); const { View, Text, TouchableOpacity } = ReactNative; diff --git a/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.tsx b/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.tsx index 7f7e727f3e4..2d34b5caa77 100644 --- a/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.tsx +++ b/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useRef, useState } from 'react'; -import HeaderCenter from '../../../../../component-library/components-temp/HeaderCenter'; +import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; import BottomSheet, { BottomSheetRef, } from '../../../../../component-library/components/BottomSheets/BottomSheet'; @@ -80,7 +80,10 @@ export const DefaultSlippageModal = () => { return ( - + {strings('bridge.default_slippage_description')} diff --git a/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/index.ts b/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/index.ts index 5b3a9f326b8..2cf981b4621 100644 --- a/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/index.ts +++ b/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/index.ts @@ -44,10 +44,15 @@ export const useBridgeQuoteRequest = () => { chainId: sourceToken?.chainId, }); + // Use simple balance check (ignoring gas fees) for quote requests to avoid circular dependencies. + // The full balance check with gas fees is used separately within the BridgeView to block user from executing + // the swap in insufficient balance. + // This prevents the infinite loop: quote request → gas data changes → insufficientBal changes → new quote request const insufficientBal = useIsInsufficientBalance({ amount: sourceAmount, token: sourceToken, latestAtomicBalance: latestSourceBalance?.atomicBalance, + ignoreGasFees: true, }); const { gasIncluded, gasIncluded7702 } = useSelector( diff --git a/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/useBridgeQuoteRequest.test.ts b/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/useBridgeQuoteRequest.test.ts index 364013fbb9b..70d2b012204 100644 --- a/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/useBridgeQuoteRequest.test.ts +++ b/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/useBridgeQuoteRequest.test.ts @@ -555,6 +555,7 @@ describe('useBridgeQuoteRequest', () => { amount: '5.5', token: testState.bridge.sourceToken, latestAtomicBalance: BigNumber.from('10000000000000000000'), + ignoreGasFees: true, }); }); diff --git a/app/components/UI/Bridge/hooks/useInsufficientBalance/index.ts b/app/components/UI/Bridge/hooks/useInsufficientBalance/index.ts index d62d2515cfe..091667be6ad 100644 --- a/app/components/UI/Bridge/hooks/useInsufficientBalance/index.ts +++ b/app/components/UI/Bridge/hooks/useInsufficientBalance/index.ts @@ -13,6 +13,12 @@ interface UseIsInsufficientBalanceParams { amount: string | undefined; token: BridgeToken | undefined; latestAtomicBalance: BigNumber | undefined; + /** + * If true, performs a simple balance check without considering gas fees. + * Used for quote requests to avoid circular dependencies. + * If false (default), includes gas fees in the calculation for UI display. + */ + ignoreGasFees?: boolean; } const normalizeAmount = (value: string, decimals: number): string => { @@ -47,17 +53,23 @@ const useIsInsufficientBalance = ({ amount, token, latestAtomicBalance, + ignoreGasFees = false, }: UseIsInsufficientBalanceParams): boolean => { const quotes = useSelector(selectBridgeQuotes); const minSolBalance = useSelector(selectMinSolBalance); // Extract only the required data from quote to prevent - // uneccessary rerenders that can use infinite loops. + // unnecessary rerenders that can cause infinite loops. + // When ignoreGasFees is true, we skip gas data to avoid circular dependencies. const bestQuote = quotes?.recommendedQuote; - const gasIncluded = bestQuote?.quote?.gasIncluded; - const gasIncluded7702 = bestQuote?.quote?.gasIncluded7702; - const gasSponsored = bestQuote?.quote?.gasSponsored; - const gasAmount = bestQuote?.gasFee?.effective?.amount; + const gasIncluded = ignoreGasFees ? false : bestQuote?.quote?.gasIncluded; + const gasIncluded7702 = ignoreGasFees + ? false + : bestQuote?.quote?.gasIncluded7702; + const gasSponsored = ignoreGasFees ? false : bestQuote?.quote?.gasSponsored; + const gasAmount = ignoreGasFees + ? undefined + : bestQuote?.gasFee?.effective?.amount; return useMemo(() => { const isValidAmount = diff --git a/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.tsx b/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.tsx index 628df4234bf..a69b89eaa1b 100644 --- a/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.tsx +++ b/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.tsx @@ -10,9 +10,7 @@ import { IconName, IconSize, } from '@metamask/design-system-react-native'; -import TextField, { - TextFieldSize, -} from '../../../../../component-library/components/Form/TextField'; +import TextField from '../../../../../component-library/components/Form/TextField'; import Label from '../../../../../component-library/components/Form/Label'; import Button, { @@ -296,7 +294,6 @@ const CardAuthentication = () => { autoCapitalize={'none'} onChangeText={handleOtpValueChange} numberOfLines={1} - size={TextFieldSize.Lg} value={confirmCode} keyboardType="number-pad" textContentType="oneTimeCode" @@ -408,7 +405,6 @@ const CardAuthentication = () => { autoComplete="one-time-code" onChangeText={handleEmailChange} numberOfLines={1} - size={TextFieldSize.Lg} value={email} returnKeyType={'next'} keyboardType="email-address" @@ -426,7 +422,6 @@ const CardAuthentication = () => { onChangeText={handlePasswordChange} autoComplete="one-time-code" numberOfLines={1} - size={TextFieldSize.Lg} value={password} maxLength={255} returnKeyType={'done'} diff --git a/app/components/UI/Card/Views/CardAuthentication/__snapshots__/CardAuthentication.test.tsx.snap b/app/components/UI/Card/Views/CardAuthentication/__snapshots__/CardAuthentication.test.tsx.snap index 95fd25a6cfe..5e7ca12233a 100644 --- a/app/components/UI/Card/Views/CardAuthentication/__snapshots__/CardAuthentication.test.tsx.snap +++ b/app/components/UI/Card/Views/CardAuthentication/__snapshots__/CardAuthentication.test.tsx.snap @@ -673,9 +673,9 @@ exports[`CardAuthentication Component Login Step - Component Rendering matches l style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc866", + "borderRadius": 12, "borderWidth": 1, "flexDirection": "row", "height": 48, @@ -700,6 +700,7 @@ exports[`CardAuthentication Component Login Step - Component Rendering matches l editable={true} keyboardType="email-address" maxLength={255} + multiline={false} numberOfLines={1} onBlur={[Function]} onChangeText={[Function]} @@ -785,9 +786,9 @@ exports[`CardAuthentication Component Login Step - Component Rendering matches l style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc866", + "borderRadius": 12, "borderWidth": 1, "flexDirection": "row", "height": 48, @@ -811,6 +812,7 @@ exports[`CardAuthentication Component Login Step - Component Rendering matches l autoFocus={false} editable={true} maxLength={255} + multiline={false} numberOfLines={1} onBlur={[Function]} onChangeText={[Function]} @@ -841,7 +843,7 @@ exports[`CardAuthentication Component Login Step - Component Rendering matches l ({ default: jest.fn(() => true), })); +// Mock push provisioning hook +const mockInitiateProvisioning = jest.fn(); +const mockResetProvisioningStatus = jest.fn(); +const mockUsePushProvisioning = jest.fn( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (_options: unknown) => ({ + initiateProvisioning: mockInitiateProvisioning, + resetStatus: mockResetProvisioningStatus, + status: 'idle' as const, + error: null, + isProvisioning: false, + isSuccess: false, + isError: false, + isLoading: false, + canAddToWallet: false, + }), +); + +jest.mock('../../pushProvisioning', () => ({ + usePushProvisioning: (options: unknown) => mockUsePushProvisioning(options), + getWalletName: jest.fn(() => 'Apple Wallet'), +})); + +// Mock @expensify/react-native-wallet +jest.mock('@expensify/react-native-wallet', () => ({ + AddToWalletButton: () => null, +})); + import { fireEvent, screen, waitFor } from '@testing-library/react-native'; import { Alert } from 'react-native'; import { useSelector } from 'react-redux'; @@ -4048,4 +4076,234 @@ describe('CardHome Component', () => { }); }); }); + + describe('Push Provisioning Integration', () => { + const mockCardDetailsWithHolder = { + type: CardType.VIRTUAL, + id: 'card-123', + holderName: 'John Doe', + panLast4: '1234', + status: 'ACTIVE', + expiryDate: '12/25', + }; + + const mockUserDetailsForProvisioning = { + id: 'user-123', + addressLine1: '123 Main St', + addressLine2: 'Apt 4B', + city: 'New York', + zip: '10001', + usState: 'NY', + mailingAddressLine1: null, + mailingAddressLine2: null, + mailingCity: null, + mailingZip: null, + mailingUsState: null, + }; + + // Helper to get the last call options from the mock + const getLastCallOptions = () => { + const calls = mockUsePushProvisioning.mock.calls; + expect(calls.length).toBeGreaterThan(0); + const lastCall = calls[calls.length - 1]; + return lastCall[0] as Record; + }; + + beforeEach(() => { + mockUsePushProvisioning.mockClear(); + mockInitiateProvisioning.mockClear(); + mockResetProvisioningStatus.mockClear(); + }); + + it('calls usePushProvisioning with cardDetails from card status', async () => { + // Given: authenticated user with card details + setupMockSelectors({ isAuthenticated: true, userLocation: 'us' }); + setupLoadCardDataMock({ + isAuthenticated: true, + isBaanxLoginEnabled: true, + cardDetails: mockCardDetailsWithHolder, + isLoading: false, + kycStatus: { + verificationState: 'VERIFIED', + userId: 'user-123', + userDetails: mockUserDetailsForProvisioning, + }, + }); + + // When: component renders + render(); + + // Then: usePushProvisioning should be called with memoized cardDetails + await waitFor(() => { + expect(mockUsePushProvisioning).toHaveBeenCalled(); + }); + + const options = getLastCallOptions(); + + // Verify cardDetails is passed correctly + expect(options.cardDetails).toEqual({ + id: 'card-123', + holderName: 'John Doe', + panLast4: '1234', + status: 'ACTIVE', + expiryDate: '12/25', + }); + }); + + it('passes userAddress derived from physical address', async () => { + // Given: US user with physical address + setupMockSelectors({ isAuthenticated: true, userLocation: 'us' }); + setupLoadCardDataMock({ + isAuthenticated: true, + isBaanxLoginEnabled: true, + cardDetails: mockCardDetailsWithHolder, + isLoading: false, + kycStatus: { + verificationState: 'VERIFIED', + userId: 'user-123', + userDetails: mockUserDetailsForProvisioning, + }, + }); + + // When: component renders + render(); + + // Then: usePushProvisioning should receive userAddress from physical address + await waitFor(() => { + expect(mockUsePushProvisioning).toHaveBeenCalled(); + }); + + const options = getLastCallOptions(); + + // Verify userAddress uses physical address fields in provisioning format + expect(options.userAddress).toEqual({ + name: 'Card Holder', // Uses default since userDetails doesn't have firstName/lastName + addressOne: '123 Main St', + addressTwo: 'Apt 4B', + locality: 'New York', + administrativeArea: 'NY', + postalCode: '10001', + countryCode: 'US', + phoneNumber: '', + }); + }); + + it('passes null cardDetails when no card exists', async () => { + // Given: authenticated user without card + setupMockSelectors({ isAuthenticated: true, userLocation: 'us' }); + setupLoadCardDataMock({ + isAuthenticated: true, + isBaanxLoginEnabled: true, + cardDetails: null, + isLoading: false, + kycStatus: { + verificationState: 'VERIFIED', + userId: 'user-123', + userDetails: mockUserDetailsForProvisioning, + }, + }); + + // When: component renders + render(); + + // Then: cardDetails should be null + await waitFor(() => { + expect(mockUsePushProvisioning).toHaveBeenCalled(); + }); + + const options = getLastCallOptions(); + + expect(options.cardDetails).toBeNull(); + }); + + it('provides onSuccess callback that shows success toast', async () => { + // Given: authenticated user with card + setupMockSelectors({ isAuthenticated: true, userLocation: 'us' }); + setupLoadCardDataMock({ + isAuthenticated: true, + isBaanxLoginEnabled: true, + cardDetails: mockCardDetailsWithHolder, + isLoading: false, + kycStatus: { + verificationState: 'VERIFIED', + userId: 'user-123', + userDetails: mockUserDetailsForProvisioning, + }, + }); + + // When: component renders + render(); + + await waitFor(() => { + expect(mockUsePushProvisioning).toHaveBeenCalled(); + }); + + // Then: onSuccess callback should be provided + const options = getLastCallOptions(); + + expect(typeof options.onSuccess).toBe('function'); + }); + + it('provides onError callback that shows error toast', async () => { + // Given: authenticated user with card + setupMockSelectors({ isAuthenticated: true, userLocation: 'us' }); + setupLoadCardDataMock({ + isAuthenticated: true, + isBaanxLoginEnabled: true, + cardDetails: mockCardDetailsWithHolder, + isLoading: false, + kycStatus: { + verificationState: 'VERIFIED', + userId: 'user-123', + userDetails: mockUserDetailsForProvisioning, + }, + }); + + // When: component renders + render(); + + await waitFor(() => { + expect(mockUsePushProvisioning).toHaveBeenCalled(); + }); + + // Then: onError callback should be provided + const options = getLastCallOptions(); + + expect(typeof options.onError).toBe('function'); + }); + + it('uses holderName from cardDetails for provisioning', async () => { + // Given: card with holder name from card status API + const cardWithHolderName = { + ...mockCardDetailsWithHolder, + holderName: 'Jane Smith', + }; + + setupMockSelectors({ isAuthenticated: true, userLocation: 'us' }); + setupLoadCardDataMock({ + isAuthenticated: true, + isBaanxLoginEnabled: true, + cardDetails: cardWithHolderName, + isLoading: false, + kycStatus: { + verificationState: 'VERIFIED', + userId: 'user-123', + userDetails: mockUserDetailsForProvisioning, + }, + }); + + // When: component renders + render(); + + await waitFor(() => { + expect(mockUsePushProvisioning).toHaveBeenCalled(); + }); + + // Then: holderName should come from cardDetails + const options = getLastCallOptions(); + const cardDetails = options.cardDetails as { holderName: string }; + + expect(cardDetails.holderName).toBe('Jane Smith'); + }); + }); }); diff --git a/app/components/UI/Card/Views/CardHome/CardHome.tsx b/app/components/UI/Card/Views/CardHome/CardHome.tsx index fc3ba750668..f70954305aa 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.tsx @@ -92,9 +92,21 @@ import { import SpendingLimitProgressBar from '../../components/SpendingLimitProgressBar/SpendingLimitProgressBar'; import { createAddFundsModalNavigationDetails } from '../../components/AddFundsBottomSheet/AddFundsBottomSheet'; import { createAssetSelectionModalNavigationDetails } from '../../components/AssetSelectionBottomSheet/AssetSelectionBottomSheet'; +import { + usePushProvisioning, + getWalletName, + type ProvisioningError, +} from '../../pushProvisioning'; +import { AddToWalletButton } from '@expensify/react-native-wallet'; import { CardScreenshotDeterrent } from '../../components/CardScreenshotDeterrent'; import { createPasswordBottomSheetNavigationDetails } from '../../components/PasswordBottomSheet'; -import type { ShippingAddress } from '../ReviewOrder'; +import { + buildProvisioningUserAddress, + buildShippingAddress, + buildCardholderName, + type ShippingAddress, +} from '../../util/buildUserAddress'; +import AnimatedSpinner from '../../../AnimatedSpinner'; /** * Route params for CardHome screen @@ -186,6 +198,7 @@ const CardHome = () => { const { navigateToCardPage, navigateToTravelPage, navigateToCardTosPage } = useNavigateToCardPage(navigation); + const { openSwaps } = useOpenSwaps({ priorityToken, }); @@ -215,6 +228,72 @@ const CardHome = () => { return balanceFiat; }, [balanceFiat, balanceFormatted]); + // Get cardholder name from user details (firstName + lastName) + const cardholderName = useMemo( + () => buildCardholderName(kycStatus?.userDetails), + [kycStatus?.userDetails], + ); + + // Build user address for Google Wallet provisioning + const userAddressForProvisioning = useMemo( + () => buildProvisioningUserAddress(kycStatus?.userDetails, cardholderName), + [kycStatus?.userDetails, cardholderName], + ); + + // Memoize cardDetails for push provisioning to avoid infinite loops + const cardDetailsForProvisioning = useMemo( + () => + cardDetails + ? { + id: cardDetails.id, + holderName: cardDetails.holderName, + panLast4: cardDetails.panLast4, + status: cardDetails.status, + expiryDate: cardDetails.expiryDate, + } + : null, + [cardDetails], + ); + + const { + initiateProvisioning: initiatePushProvisioning, + isProvisioning: isPushProvisioning, + canAddToWallet, + } = usePushProvisioning({ + cardDetails: cardDetailsForProvisioning, + userAddress: userAddressForProvisioning, + onSuccess: () => { + toastRef?.current?.showToast({ + variant: ToastVariants.Icon, + labelOptions: [ + { + label: strings('card.push_provisioning.success_message', { + walletName: getWalletName(), + }), + }, + ], + iconName: IconName.Confirmation, + iconColor: theme.colors.success.default, + hasNoTimeout: false, + }); + }, + onError: (error: ProvisioningError) => { + toastRef?.current?.showToast({ + variant: ToastVariants.Icon, + labelOptions: [ + { + label: + error.message || strings('card.push_provisioning.error_unknown'), + }, + ], + iconName: IconName.Danger, + iconColor: theme.colors.error.default, + backgroundColor: theme.colors.error.muted, + hasNoTimeout: false, + }); + }, + }); + const handleRefresh = useCallback(async () => { setIsRefreshing(true); try { @@ -426,42 +505,10 @@ const CardHome = () => { ); }, [logoutFromProvider, navigation]); - const userShippingAddress: ShippingAddress | undefined = useMemo(() => { - const userDetails = kycStatus?.userDetails; - if (!userDetails) { - return undefined; - } - - const mailingLine1 = userDetails.mailingAddressLine1; - const mailingCity = userDetails.mailingCity; - const mailingZip = userDetails.mailingZip; - - if (mailingLine1 && mailingCity && mailingZip) { - return { - line1: mailingLine1, - line2: userDetails.mailingAddressLine2 ?? undefined, - city: mailingCity, - state: userDetails.mailingUsState ?? '', - zip: mailingZip, - }; - } - - const physicalLine1 = userDetails.addressLine1; - const physicalCity = userDetails.city; - const physicalZip = userDetails.zip; - - if (physicalLine1 && physicalCity && physicalZip) { - return { - line1: physicalLine1, - line2: userDetails.addressLine2 ?? undefined, - city: physicalCity, - state: userDetails.usState ?? '', - zip: physicalZip, - }; - } - - return undefined; - }, [kycStatus]); + const userShippingAddress: ShippingAddress | undefined = useMemo( + () => buildShippingAddress(kycStatus?.userDetails), + [kycStatus?.userDetails], + ); const orderMetalCardAction = useCallback(() => { trackEvent( @@ -658,7 +705,7 @@ const CardHome = () => { if (!isBaanxLoginEnabled) { return ( + + + + ); +}; + +export default ClaimOnLineaBottomSheet; diff --git a/app/components/UI/Earn/components/MerklRewards/ClaimOnLineaBottomSheet/index.ts b/app/components/UI/Earn/components/MerklRewards/ClaimOnLineaBottomSheet/index.ts new file mode 100644 index 00000000000..b07d3f849ce --- /dev/null +++ b/app/components/UI/Earn/components/MerklRewards/ClaimOnLineaBottomSheet/index.ts @@ -0,0 +1 @@ +export { default } from './ClaimOnLineaBottomSheet'; diff --git a/app/components/UI/Earn/components/MerklRewards/hooks/useMerklClaim.test.ts b/app/components/UI/Earn/components/MerklRewards/hooks/useMerklClaim.test.ts index 4f0a79558b2..13288b6255d 100644 --- a/app/components/UI/Earn/components/MerklRewards/hooks/useMerklClaim.test.ts +++ b/app/components/UI/Earn/components/MerklRewards/hooks/useMerklClaim.test.ts @@ -492,4 +492,56 @@ describe('useMerklClaim', () => { // isClaiming stays true - component will unmount and useMerklClaimStatus handles the rest expect(result.current.isClaiming).toBe(true); }); + + it('does not set error when user rejects the transaction (EIP-1193 code 4001)', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => createMockRewardData(), + }); + + // Create error with EIP-1193 user rejection code + const userRejectionError = Object.assign( + new Error('User rejected the request'), + { code: 4001 }, + ); + mockAddTransaction.mockRejectedValueOnce(userRejectionError); + + const { result } = renderHook(() => useMerklClaim(mockAsset)); + + await act(async () => { + try { + await result.current.claimRewards(); + } catch { + // Expected to throw + } + }); + + // Error should NOT be set for user rejection (code 4001) + expect(result.current.error).toBe(null); + expect(result.current.isClaiming).toBe(false); + }); + + it('sets error for non-user-rejection errors (no code 4001)', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => createMockRewardData(), + }); + + // Error without code 4001 should set error state + mockAddTransaction.mockRejectedValueOnce(new Error('Network error')); + + const { result } = renderHook(() => useMerklClaim(mockAsset)); + + await act(async () => { + try { + await result.current.claimRewards(); + } catch { + // Expected to throw + } + }); + + // Error SHOULD be set for non-user-rejection errors + expect(result.current.error).toBe('Network error'); + expect(result.current.isClaiming).toBe(false); + }); }); diff --git a/app/components/UI/Earn/components/MerklRewards/hooks/useMerklClaim.ts b/app/components/UI/Earn/components/MerklRewards/hooks/useMerklClaim.ts index 0d18b625c86..b4816c81800 100644 --- a/app/components/UI/Earn/components/MerklRewards/hooks/useMerklClaim.ts +++ b/app/components/UI/Earn/components/MerklRewards/hooks/useMerklClaim.ts @@ -128,12 +128,19 @@ export const useMerklClaim = (asset: TokenI) => { return { txHash, transactionMeta }; } catch (e) { + const error = e as Error & { code?: number }; + // Ignore AbortError - component unmounted or request was cancelled - if ((e as Error).name === 'AbortError') { + if (error.name === 'AbortError') { return undefined; } - const errorMessage = (e as Error).message; - setError(errorMessage); + + // Don't show error if user rejected/cancelled the transaction (EIP-1193 code 4001) + const isUserRejection = error.code === 4001; + + if (!isUserRejection) { + setError(error.message); + } setIsClaiming(false); throw e; } diff --git a/app/components/UI/Earn/components/MerklRewards/hooks/useMerklRewards.test.ts b/app/components/UI/Earn/components/MerklRewards/hooks/useMerklRewards.test.ts index 5793081d71f..069bd225748 100644 --- a/app/components/UI/Earn/components/MerklRewards/hooks/useMerklRewards.test.ts +++ b/app/components/UI/Earn/components/MerklRewards/hooks/useMerklRewards.test.ts @@ -483,6 +483,99 @@ describe('useMerklRewards', () => { expect(result.current.claimableReward).toBe(null); }); + it('formats single decimal values to 2 decimal places (0.9 -> 0.90)', async () => { + const mockRewardData = { + token: { + address: AGLAMERKL_ADDRESS_MAINNET, + chainId: 1, + symbol: 'aglaMerkl', + decimals: 18, + price: null, + }, + accumulated: '0', + unclaimed: '900000000000000000', // 0.9 tokens + pending: '0', + proofs: [], + amount: '900000000000000000', + claimed: '0', + recipient: mockSelectedAddress, + }; + + mockFetchMerklRewardsForAsset.mockResolvedValueOnce(mockRewardData); + mockGetClaimedAmountFromContract.mockResolvedValueOnce('0'); + // Simulate renderFromTokenMinimalUnit returning a value without trailing zero + mockRenderFromTokenMinimalUnit.mockReturnValueOnce('0.9'); + + const { result } = renderHook(() => useMerklRewards({ asset: mockAsset })); + + await waitFor(() => { + // Should format to 2 decimal places + expect(result.current.claimableReward).toBe('0.90'); + }); + }); + + it('formats whole numbers to 2 decimal places (1 -> 1.00)', async () => { + const mockRewardData = { + token: { + address: AGLAMERKL_ADDRESS_MAINNET, + chainId: 1, + symbol: 'aglaMerkl', + decimals: 18, + price: null, + }, + accumulated: '0', + unclaimed: '1000000000000000000', // 1 token + pending: '0', + proofs: [], + amount: '1000000000000000000', + claimed: '0', + recipient: mockSelectedAddress, + }; + + mockFetchMerklRewardsForAsset.mockResolvedValueOnce(mockRewardData); + mockGetClaimedAmountFromContract.mockResolvedValueOnce('0'); + // Simulate renderFromTokenMinimalUnit returning a whole number + mockRenderFromTokenMinimalUnit.mockReturnValueOnce('1'); + + const { result } = renderHook(() => useMerklRewards({ asset: mockAsset })); + + await waitFor(() => { + // Should format to 2 decimal places + expect(result.current.claimableReward).toBe('1.00'); + }); + }); + + it('formats values like 12.5 to 12.50', async () => { + const mockRewardData = { + token: { + address: AGLAMERKL_ADDRESS_MAINNET, + chainId: 1, + symbol: 'aglaMerkl', + decimals: 18, + price: null, + }, + accumulated: '0', + unclaimed: '12500000000000000000', // 12.5 tokens + pending: '0', + proofs: [], + amount: '12500000000000000000', + claimed: '0', + recipient: mockSelectedAddress, + }; + + mockFetchMerklRewardsForAsset.mockResolvedValueOnce(mockRewardData); + mockGetClaimedAmountFromContract.mockResolvedValueOnce('0'); + // Simulate renderFromTokenMinimalUnit returning single decimal + mockRenderFromTokenMinimalUnit.mockReturnValueOnce('12.5'); + + const { result } = renderHook(() => useMerklRewards({ asset: mockAsset })); + + await waitFor(() => { + // Should format to 2 decimal places + expect(result.current.claimableReward).toBe('12.50'); + }); + }); + it('converts "< 0.00001" to "< 0.01" for small amounts', async () => { const mockRewardData = { token: { diff --git a/app/components/UI/Earn/components/MerklRewards/hooks/useMerklRewards.ts b/app/components/UI/Earn/components/MerklRewards/hooks/useMerklRewards.ts index 2221f8d3def..bd2b51865f4 100644 --- a/app/components/UI/Earn/components/MerklRewards/hooks/useMerklRewards.ts +++ b/app/components/UI/Earn/components/MerklRewards/hooks/useMerklRewards.ts @@ -147,9 +147,15 @@ export const useMerklRewards = ({ ); // Handle the "< 0.00001" case from renderFromTokenMinimalUnit // by showing "< 0.01" for consistency with 2 decimal places - const displayAmount = unclaimedAmount.startsWith('<') - ? '< 0.01' - : unclaimedAmount; + // Also ensure we always show exactly 2 decimal places for currency display + let displayAmount: string; + if (unclaimedAmount.startsWith('<')) { + displayAmount = '< 0.01'; + } else { + // Ensure exactly 2 decimal places (e.g., "0.9" -> "0.90") + const numValue = parseFloat(unclaimedAmount); + displayAmount = numValue.toFixed(2); + } // Double-check that the rendered amount is not '0' or '0.00' // This handles edge cases where very small amounts round to zero if ( diff --git a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx index 27277857b6e..c0aa7915c12 100644 --- a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx +++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx @@ -13,7 +13,10 @@ import renderWithProvider from '../../../../../../util/test/renderWithProvider'; import MusdConversionAssetListCta from '.'; import { useMusdConversionFlowData } from '../../../hooks/useMusdConversionFlowData'; import { useMusdConversion } from '../../../hooks/useMusdConversion'; -import { useMusdCtaVisibility } from '../../../hooks/useMusdCtaVisibility'; +import { + BUY_GET_MUSD_CTA_VARIANT, + useMusdCtaVisibility, +} from '../../../hooks/useMusdCtaVisibility'; import { useRampNavigation } from '../../../../Ramp/hooks/useRampNavigation'; import { MUSD_CONVERSION_APY, @@ -77,7 +80,9 @@ describe('MusdConversionAssetListCta', () => { ( useNetworkName as jest.MockedFunction - ).mockReturnValue('Ethereum Mainnet'); + ).mockImplementation((chainId) => + chainId ? 'Ethereum Mainnet' : undefined, + ); ( useRampNavigation as jest.MockedFunction @@ -132,6 +137,7 @@ describe('MusdConversionAssetListCta', () => { showNetworkIcon: false, selectedChainId: null, isEmptyWallet: false, + variant: BUY_GET_MUSD_CTA_VARIANT.GET, }), shouldShowTokenListItemCta: jest.fn(), shouldShowAssetOverviewCta: jest.fn(), @@ -181,7 +187,7 @@ describe('MusdConversionAssetListCta', () => { }); describe('CTA button text', () => { - it('displays "Buy mUSD" when hook returns isEmptyWallet true', () => { + it('displays "Buy mUSD" when CTA variant is BUY', () => { ( useMusdConversionFlowData as jest.MockedFunction< typeof useMusdConversionFlowData @@ -201,6 +207,20 @@ describe('MusdConversionAssetListCta', () => { isMusdBuyable: false, }); + ( + useMusdCtaVisibility as jest.MockedFunction + ).mockReturnValue({ + shouldShowBuyGetMusdCta: jest.fn().mockReturnValue({ + shouldShowCta: true, + showNetworkIcon: false, + selectedChainId: null, + isEmptyWallet: true, + variant: BUY_GET_MUSD_CTA_VARIANT.BUY, + }), + shouldShowTokenListItemCta: jest.fn(), + shouldShowAssetOverviewCta: jest.fn(), + }); + const { getByText } = renderWithProvider(, { state: initialRootState, }); @@ -225,6 +245,7 @@ describe('MusdConversionAssetListCta', () => { showNetworkIcon: false, selectedChainId: null, isEmptyWallet: false, + variant: null, }), shouldShowTokenListItemCta: jest.fn(), shouldShowAssetOverviewCta: jest.fn(), @@ -264,6 +285,20 @@ describe('MusdConversionAssetListCta', () => { isMusdBuyableOnAnyChain: false, isMusdBuyable: false, }); + + ( + useMusdCtaVisibility as jest.MockedFunction + ).mockReturnValue({ + shouldShowBuyGetMusdCta: jest.fn().mockReturnValue({ + shouldShowCta: true, + showNetworkIcon: false, + selectedChainId: null, + isEmptyWallet: true, + variant: BUY_GET_MUSD_CTA_VARIANT.BUY, + }), + shouldShowTokenListItemCta: jest.fn(), + shouldShowAssetOverviewCta: jest.fn(), + }); }); it('calls goToBuy with correct ramp intent', () => { @@ -278,6 +313,23 @@ describe('MusdConversionAssetListCta', () => { }); }); + it('calls goToBuy when earn percentage text is pressed', () => { + const { getByText } = renderWithProvider(, { + state: initialRootState, + }); + + const bonusText = strings('earn.earn_a_percentage_bonus', { + percentage: MUSD_CONVERSION_APY, + }); + + const bonusTextElement = getByText(bonusText); + fireEvent.press(bonusTextElement.parent as never); + + expect(mockGoToBuy).toHaveBeenCalledWith({ + assetId: MUSD_TOKEN_ASSET_ID_BY_CHAIN[MUSD_CONVERSION_DEFAULT_CHAIN_ID], + }); + }); + it('does not call initiateConversion when wallet is empty', () => { const { getByText } = renderWithProvider(, { state: initialRootState, @@ -344,6 +396,8 @@ describe('MusdConversionAssetListCta', () => { shouldShowCta: true, showNetworkIcon: false, selectedChainId: CHAIN_IDS.LINEA_MAINNET, + isEmptyWallet: false, + variant: BUY_GET_MUSD_CTA_VARIANT.GET, }), shouldShowTokenListItemCta: jest.fn(), shouldShowAssetOverviewCta: jest.fn(), @@ -399,6 +453,8 @@ describe('MusdConversionAssetListCta', () => { shouldShowCta: true, showNetworkIcon: false, selectedChainId: CHAIN_IDS.LINEA_MAINNET, + isEmptyWallet: false, + variant: BUY_GET_MUSD_CTA_VARIANT.GET, }), shouldShowTokenListItemCta: jest.fn(), shouldShowAssetOverviewCta: jest.fn(), @@ -505,6 +561,7 @@ describe('MusdConversionAssetListCta', () => { showNetworkIcon: false, selectedChainId: null, isEmptyWallet: false, + variant: null, }), shouldShowTokenListItemCta: jest.fn(), shouldShowAssetOverviewCta: jest.fn(), @@ -529,6 +586,7 @@ describe('MusdConversionAssetListCta', () => { showNetworkIcon: false, selectedChainId: null, isEmptyWallet: true, + variant: BUY_GET_MUSD_CTA_VARIANT.BUY, }), shouldShowTokenListItemCta: jest.fn(), shouldShowAssetOverviewCta: jest.fn(), @@ -555,6 +613,7 @@ describe('MusdConversionAssetListCta', () => { showNetworkIcon: false, selectedChainId: null, isEmptyWallet: true, + variant: BUY_GET_MUSD_CTA_VARIANT.BUY, }), shouldShowTokenListItemCta: jest.fn(), shouldShowAssetOverviewCta: jest.fn(), @@ -581,6 +640,7 @@ describe('MusdConversionAssetListCta', () => { showNetworkIcon: true, selectedChainId: CHAIN_IDS.MAINNET, isEmptyWallet: true, + variant: BUY_GET_MUSD_CTA_VARIANT.BUY, }), shouldShowTokenListItemCta: jest.fn(), shouldShowAssetOverviewCta: jest.fn(), @@ -605,6 +665,7 @@ describe('MusdConversionAssetListCta', () => { showNetworkIcon: true, selectedChainId: CHAIN_IDS.LINEA_MAINNET, isEmptyWallet: true, + variant: BUY_GET_MUSD_CTA_VARIANT.BUY, }), shouldShowTokenListItemCta: jest.fn(), shouldShowAssetOverviewCta: jest.fn(), @@ -629,6 +690,7 @@ describe('MusdConversionAssetListCta', () => { showNetworkIcon: true, selectedChainId: CHAIN_IDS.BSC, isEmptyWallet: true, + variant: BUY_GET_MUSD_CTA_VARIANT.BUY, }), shouldShowTokenListItemCta: jest.fn(), shouldShowAssetOverviewCta: jest.fn(), @@ -645,25 +707,42 @@ describe('MusdConversionAssetListCta', () => { }); }); - describe('MetaMetrics', () => { + describe('event tracking (MetaMetrics)', () => { const { EVENT_LOCATIONS, MUSD_CTA_TYPES } = MUSD_EVENTS_CONSTANTS; - it('tracks mUSD conversion CTA clicked event when Buy mUSD is pressed', () => { - // Arrange + interface ArrangeOptions { + isEmptyWallet: boolean; + selectedChainId: Hex | null; + hasSeenConversionEducationScreen: boolean; + } + + const arrange = ({ + isEmptyWallet, + selectedChainId, + hasSeenConversionEducationScreen, + }: ArrangeOptions) => { + ( + useMusdConversion as jest.MockedFunction + ).mockReturnValue({ + initiateConversion: mockInitiateConversion, + error: null, + hasSeenConversionEducationScreen, + }); + ( useMusdConversionFlowData as jest.MockedFunction< typeof useMusdConversionFlowData > ).mockReturnValue({ - isEmptyWallet: true, + isEmptyWallet, getPaymentTokenForSelectedNetwork: mockGetPreferredPaymentToken, getChainIdForBuyFlow: mockGetChainIdForBuyFlow, isPopularNetworksFilterActive: false, - selectedChainId: null, - selectedChains: [], + selectedChainId, + selectedChains: selectedChainId ? [selectedChainId] : [], isGeoEligible: true, - hasConvertibleTokens: false, - conversionTokens: [], + hasConvertibleTokens: !isEmptyWallet, + conversionTokens: isEmptyWallet ? [] : [mockConversionToken], isMusdBuyableOnChain: {}, isMusdBuyableOnAnyChain: false, isMusdBuyable: false, @@ -674,118 +753,228 @@ describe('MusdConversionAssetListCta', () => { ).mockReturnValue({ shouldShowBuyGetMusdCta: jest.fn().mockReturnValue({ shouldShowCta: true, - showNetworkIcon: false, - selectedChainId: null, - isEmptyWallet: true, + showNetworkIcon: Boolean(selectedChainId), + selectedChainId, + isEmptyWallet, + variant: isEmptyWallet + ? BUY_GET_MUSD_CTA_VARIANT.BUY + : BUY_GET_MUSD_CTA_VARIANT.GET, }), shouldShowTokenListItemCta: jest.fn(), shouldShowAssetOverviewCta: jest.fn(), }); - const { getByText } = renderWithProvider(, { + return renderWithProvider(, { state: initialRootState, }); + }; - // Act - fireEvent.press(getByText(strings('earn.musd_conversion.buy_musd'))); - - // Assert + const expectTrackedEventProps = ( + expectedProps: Record, + ) => { expect(mockCreateEventBuilder).toHaveBeenCalledTimes(1); expect(mockCreateEventBuilder).toHaveBeenCalledWith( MetaMetricsEvents.MUSD_CONVERSION_CTA_CLICKED, ); expect(mockAddProperties).toHaveBeenCalledTimes(1); - expect(mockAddProperties).toHaveBeenCalledWith({ - location: EVENT_LOCATIONS.HOME_SCREEN, - redirects_to: EVENT_LOCATIONS.BUY_SCREEN, - cta_type: MUSD_CTA_TYPES.PRIMARY, - cta_text: strings('earn.musd_conversion.buy_musd'), - network_chain_id: MUSD_CONVERSION_DEFAULT_CHAIN_ID, - network_name: 'Ethereum Mainnet', - }); + expect(mockAddProperties).toHaveBeenCalledWith(expectedProps); expect(mockTrackEvent).toHaveBeenCalledTimes(1); expect(mockTrackEvent).toHaveBeenCalledWith({ name: 'mock-built-event' }); - }); + }; - // TODO: Missing test case: tracks mUSD conversion CTA clicked event when Get mUSD is pressed and education screen already seen - it('tracks mUSD conversion CTA clicked event when Get mUSD is pressed and education screen has not been seen', async () => { - // Arrange - ( - useMusdConversion as jest.MockedFunction - ).mockReturnValue({ - initiateConversion: mockInitiateConversion, - error: null, - hasSeenConversionEducationScreen: false, - }); + type GetByText = ReturnType['getByText']; - const { getByText } = renderWithProvider(, { - state: initialRootState, + const pressCtaButton = (getByText: GetByText, isEmptyWallet: boolean) => { + const buttonLabel = strings( + isEmptyWallet + ? 'earn.musd_conversion.buy_musd' + : 'earn.musd_conversion.get_musd', + ); + fireEvent.press(getByText(buttonLabel)); + return buttonLabel; + }; + + const pressEarnBonusText = (getByText: GetByText) => { + const bonusText = strings('earn.earn_a_percentage_bonus', { + percentage: MUSD_CONVERSION_APY, + }); + const bonusTextElement = getByText(bonusText); + fireEvent.press(bonusTextElement.parent as never); + return bonusText; + }; + + describe('network_name', () => { + it('uses network name when selectedChainId is defined', () => { + const { getByText } = arrange({ + isEmptyWallet: true, + selectedChainId: CHAIN_IDS.MAINNET, + hasSeenConversionEducationScreen: true, + }); + + const ctaText = pressCtaButton(getByText, true); + + expectTrackedEventProps({ + location: EVENT_LOCATIONS.HOME_SCREEN, + redirects_to: EVENT_LOCATIONS.BUY_SCREEN, + cta_type: MUSD_CTA_TYPES.PRIMARY, + cta_text: ctaText, + cta_click_target: 'cta_button', + network_chain_id: CHAIN_IDS.MAINNET, + network_name: 'Ethereum Mainnet', + }); }); - // Act - await act(async () => { - fireEvent.press(getByText(strings('earn.musd_conversion.get_musd'))); + it('falls back to "popular networks" when selectedChainId is undefined', () => { + const { getByText } = arrange({ + isEmptyWallet: true, + selectedChainId: null, + hasSeenConversionEducationScreen: true, + }); + + const ctaText = pressCtaButton(getByText, true); + + expectTrackedEventProps({ + location: EVENT_LOCATIONS.HOME_SCREEN, + redirects_to: EVENT_LOCATIONS.BUY_SCREEN, + cta_type: MUSD_CTA_TYPES.PRIMARY, + cta_text: ctaText, + cta_click_target: 'cta_button', + network_chain_id: null, + network_name: strings('wallet.popular_networks'), + }); }); + }); - // Assert - expect(mockCreateEventBuilder).toHaveBeenCalledTimes(1); - expect(mockCreateEventBuilder).toHaveBeenCalledWith( - MetaMetricsEvents.MUSD_CONVERSION_CTA_CLICKED, - ); + describe('cta_text', () => { + it('tracks "Buy mUSD" when the CTA button is clicked and wallet is empty', () => { + const { getByText } = arrange({ + isEmptyWallet: true, + selectedChainId: null, + hasSeenConversionEducationScreen: true, + }); - expect(mockAddProperties).toHaveBeenCalledTimes(1); - expect(mockAddProperties).toHaveBeenCalledWith({ - location: EVENT_LOCATIONS.HOME_SCREEN, - redirects_to: EVENT_LOCATIONS.CONVERSION_EDUCATION_SCREEN, - cta_type: MUSD_CTA_TYPES.PRIMARY, - cta_text: strings('earn.musd_conversion.get_musd'), - network_chain_id: MUSD_CONVERSION_DEFAULT_CHAIN_ID, - network_name: 'Ethereum Mainnet', + const ctaText = pressCtaButton(getByText, true); + + expectTrackedEventProps({ + location: EVENT_LOCATIONS.HOME_SCREEN, + redirects_to: EVENT_LOCATIONS.BUY_SCREEN, + cta_type: MUSD_CTA_TYPES.PRIMARY, + cta_text: ctaText, + cta_click_target: 'cta_button', + network_chain_id: null, + network_name: strings('wallet.popular_networks'), + }); }); - expect(mockTrackEvent).toHaveBeenCalledTimes(1); - expect(mockTrackEvent).toHaveBeenCalledWith({ name: 'mock-built-event' }); - }); + it('tracks "Get mUSD" when the CTA button is clicked and wallet has tokens', async () => { + const { getByText } = arrange({ + isEmptyWallet: false, + selectedChainId: null, + hasSeenConversionEducationScreen: true, + }); - it('tracks mUSD conversion CTA clicked event when Get mUSD is pressed and education screen has been seen', async () => { - // Arrange - ( - useMusdConversion as jest.MockedFunction - ).mockReturnValue({ - initiateConversion: mockInitiateConversion, - error: null, - hasSeenConversionEducationScreen: true, + await act(async () => { + pressCtaButton(getByText, false); + }); + + expectTrackedEventProps({ + location: EVENT_LOCATIONS.HOME_SCREEN, + redirects_to: EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN, + cta_type: MUSD_CTA_TYPES.PRIMARY, + cta_text: strings('earn.musd_conversion.get_musd'), + cta_click_target: 'cta_button', + network_chain_id: null, + network_name: strings('wallet.popular_networks'), + }); }); - const { getByText } = renderWithProvider(, { - state: initialRootState, + it('tracks "Earn a X% bonus" when the earn percentage text is clicked', () => { + const { getByText } = arrange({ + isEmptyWallet: true, + selectedChainId: null, + hasSeenConversionEducationScreen: true, + }); + + const ctaText = pressEarnBonusText(getByText); + + expectTrackedEventProps({ + location: EVENT_LOCATIONS.HOME_SCREEN, + redirects_to: EVENT_LOCATIONS.BUY_SCREEN, + cta_type: MUSD_CTA_TYPES.PRIMARY, + cta_text: ctaText, + cta_click_target: 'cta_text_link', + network_chain_id: null, + network_name: strings('wallet.popular_networks'), + }); }); + }); - // Act - await act(async () => { - fireEvent.press(getByText(strings('earn.musd_conversion.get_musd'))); + describe('redirects_to', () => { + it('tracks BUY_SCREEN when wallet is empty', () => { + const { getByText } = arrange({ + isEmptyWallet: true, + selectedChainId: null, + hasSeenConversionEducationScreen: true, + }); + + const ctaText = pressCtaButton(getByText, true); + + expectTrackedEventProps({ + location: EVENT_LOCATIONS.HOME_SCREEN, + redirects_to: EVENT_LOCATIONS.BUY_SCREEN, + cta_type: MUSD_CTA_TYPES.PRIMARY, + cta_text: ctaText, + cta_click_target: 'cta_button', + network_chain_id: null, + network_name: strings('wallet.popular_networks'), + }); }); - // Assert - expect(mockCreateEventBuilder).toHaveBeenCalledTimes(1); - expect(mockCreateEventBuilder).toHaveBeenCalledWith( - MetaMetricsEvents.MUSD_CONVERSION_CTA_CLICKED, - ); + it('tracks CONVERSION_EDUCATION_SCREEN when wallet has tokens and education has not been seen', async () => { + const { getByText } = arrange({ + isEmptyWallet: false, + selectedChainId: null, + hasSeenConversionEducationScreen: false, + }); - expect(mockAddProperties).toHaveBeenCalledTimes(1); - expect(mockAddProperties).toHaveBeenCalledWith({ - location: EVENT_LOCATIONS.HOME_SCREEN, - redirects_to: EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN, - cta_type: MUSD_CTA_TYPES.PRIMARY, - cta_text: strings('earn.musd_conversion.get_musd'), - network_chain_id: MUSD_CONVERSION_DEFAULT_CHAIN_ID, - network_name: 'Ethereum Mainnet', + await act(async () => { + pressCtaButton(getByText, false); + }); + + expectTrackedEventProps({ + location: EVENT_LOCATIONS.HOME_SCREEN, + redirects_to: EVENT_LOCATIONS.CONVERSION_EDUCATION_SCREEN, + cta_type: MUSD_CTA_TYPES.PRIMARY, + cta_text: strings('earn.musd_conversion.get_musd'), + cta_click_target: 'cta_button', + network_chain_id: null, + network_name: strings('wallet.popular_networks'), + }); }); - expect(mockTrackEvent).toHaveBeenCalledTimes(1); - expect(mockTrackEvent).toHaveBeenCalledWith({ name: 'mock-built-event' }); + it('tracks CUSTOM_AMOUNT_SCREEN when wallet has tokens and education has been seen', async () => { + const { getByText } = arrange({ + isEmptyWallet: false, + selectedChainId: null, + hasSeenConversionEducationScreen: true, + }); + + await act(async () => { + pressCtaButton(getByText, false); + }); + + expectTrackedEventProps({ + location: EVENT_LOCATIONS.HOME_SCREEN, + redirects_to: EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN, + cta_type: MUSD_CTA_TYPES.PRIMARY, + cta_text: strings('earn.musd_conversion.get_musd'), + cta_click_target: 'cta_button', + network_chain_id: null, + network_name: strings('wallet.popular_networks'), + }); + }); }); }); }); diff --git a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/index.tsx b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/index.tsx index 909bde78af0..69b464d8c38 100644 --- a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/index.tsx +++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/index.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View } from 'react-native'; +import { TouchableOpacity, View } from 'react-native'; import styleSheet from './MusdConversionAssetListCta.styles'; import Text, { TextVariant, @@ -12,7 +12,6 @@ import { } from '@metamask/design-system-react-native'; import { MUSD_CONVERSION_APY, - MUSD_CONVERSION_DEFAULT_CHAIN_ID, MUSD_TOKEN, MUSD_TOKEN_ASSET_ID_BY_CHAIN, } from '../../../constants/musd'; @@ -23,7 +22,10 @@ import { EARN_TEST_IDS } from '../../../constants/testIds'; import Logger from '../../../../../../util/Logger'; import { useStyles } from '../../../../../hooks/useStyles'; import { useMusdConversion } from '../../../hooks/useMusdConversion'; -import { useMusdCtaVisibility } from '../../../hooks/useMusdCtaVisibility'; +import { + BUY_GET_MUSD_CTA_VARIANT, + useMusdCtaVisibility, +} from '../../../hooks/useMusdCtaVisibility'; import { useMusdConversionFlowData } from '../../../hooks/useMusdConversionFlowData'; import AvatarToken from '../../../../../../component-library/components/Avatars/Avatar/variants/AvatarToken'; import { AvatarSize } from '../../../../../../component-library/components/Avatars/Avatar'; @@ -38,16 +40,18 @@ import { MetaMetricsEvents, useMetrics } from '../../../../../hooks/useMetrics'; import { MUSD_EVENTS_CONSTANTS } from '../../../constants/events'; import { useNetworkName } from '../../../../../Views/confirmations/hooks/useNetworkName'; +enum CTA_CLICK_TARGET { + CTA_BUTTON = 'cta_button', + CTA_TEXT_LINK = 'cta_text_link', +} + const MusdConversionAssetListCta = () => { const { styles } = useStyles(styleSheet, {}); const { goToBuy } = useRampNavigation(); - const { - isEmptyWallet, - getPaymentTokenForSelectedNetwork, - getChainIdForBuyFlow, - } = useMusdConversionFlowData(); + const { getPaymentTokenForSelectedNetwork, getChainIdForBuyFlow } = + useMusdConversionFlowData(); const { initiateConversion, hasSeenConversionEducationScreen } = useMusdConversion(); @@ -56,22 +60,21 @@ const MusdConversionAssetListCta = () => { const { trackEvent, createEventBuilder } = useMetrics(); - const { shouldShowCta, showNetworkIcon, selectedChainId } = + const { shouldShowCta, showNetworkIcon, selectedChainId, variant } = shouldShowBuyGetMusdCta(); - const networkName = useNetworkName( - selectedChainId ?? MUSD_CONVERSION_DEFAULT_CHAIN_ID, - ); + const networkName = useNetworkName(selectedChainId ?? undefined); - const ctaText = isEmptyWallet - ? strings('earn.musd_conversion.buy_musd') - : strings('earn.musd_conversion.get_musd'); + const buttonText = + variant === BUY_GET_MUSD_CTA_VARIANT.BUY + ? strings('earn.musd_conversion.buy_musd') + : strings('earn.musd_conversion.get_musd'); - const submitCtaPressedEvent = () => { + const submitCtaPressedEvent = (source: CTA_CLICK_TARGET) => { const { MUSD_CTA_TYPES, EVENT_LOCATIONS } = MUSD_EVENTS_CONSTANTS; const getRedirectLocation = () => { - if (isEmptyWallet) { + if (variant === BUY_GET_MUSD_CTA_VARIANT.BUY) { return EVENT_LOCATIONS.BUY_SCREEN; } @@ -80,6 +83,13 @@ const MusdConversionAssetListCta = () => { : EVENT_LOCATIONS.CONVERSION_EDUCATION_SCREEN; }; + const ctaText = + source === CTA_CLICK_TARGET.CTA_BUTTON + ? buttonText + : strings('earn.earn_a_percentage_bonus', { + percentage: MUSD_CONVERSION_APY, + }); + trackEvent( createEventBuilder(MetaMetricsEvents.MUSD_CONVERSION_CTA_CLICKED) .addProperties({ @@ -87,17 +97,18 @@ const MusdConversionAssetListCta = () => { redirects_to: getRedirectLocation(), cta_type: MUSD_CTA_TYPES.PRIMARY, cta_text: ctaText, - network_chain_id: selectedChainId || MUSD_CONVERSION_DEFAULT_CHAIN_ID, - network_name: networkName, + cta_click_target: source, + network_chain_id: selectedChainId, + network_name: networkName ?? strings('wallet.popular_networks'), }) .build(), ); }; - const handlePress = async () => { - submitCtaPressedEvent(); + const handlePress = async (source: CTA_CLICK_TARGET) => { + submitCtaPressedEvent(source); - if (isEmptyWallet) { + if (variant === BUY_GET_MUSD_CTA_VARIANT.BUY) { const chainId = getChainIdForBuyFlow(); const rampIntent: RampIntent = { assetId: MUSD_TOKEN_ASSET_ID_BY_CHAIN[chainId], @@ -169,21 +180,25 @@ const MusdConversionAssetListCta = () => { MetaMask USD - - {strings('earn.earn_a_percentage_bonus', { - percentage: MUSD_CONVERSION_APY, - })} - + handlePress(CTA_CLICK_TARGET.CTA_TEXT_LINK)} + > + + {strings('earn.earn_a_percentage_bonus', { + percentage: MUSD_CONVERSION_APY, + })} + + diff --git a/app/components/UI/Earn/constants/musd.test.ts b/app/components/UI/Earn/constants/musd.test.ts new file mode 100644 index 00000000000..6c44ed7bcdb --- /dev/null +++ b/app/components/UI/Earn/constants/musd.test.ts @@ -0,0 +1,46 @@ +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { isMusdToken, MUSD_TOKEN_ADDRESS_BY_CHAIN } from './musd'; + +describe('isMusdToken', () => { + const MUSD_ADDRESS = MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.MAINNET]; + + it('returns true for mUSD token address in lowercase', () => { + const result = isMusdToken(MUSD_ADDRESS); + + expect(result).toBe(true); + }); + + it('returns true for mUSD token address in uppercase', () => { + const result = isMusdToken(MUSD_ADDRESS.toUpperCase()); + + expect(result).toBe(true); + }); + + it('returns true for mUSD token address with mixed case', () => { + const mixedCaseAddress = '0xAcA92E438df0B2401fF60dA7E4337B687a2435DA'; + + const result = isMusdToken(mixedCaseAddress); + + expect(result).toBe(true); + }); + + it('returns false for non-mUSD token address', () => { + const otherAddress = '0x1234567890123456789012345678901234567890'; + + const result = isMusdToken(otherAddress); + + expect(result).toBe(false); + }); + + it('returns false for undefined address', () => { + const result = isMusdToken(undefined); + + expect(result).toBe(false); + }); + + it('returns false for empty string address', () => { + const result = isMusdToken(''); + + expect(result).toBe(false); + }); +}); diff --git a/app/components/UI/Earn/constants/musd.ts b/app/components/UI/Earn/constants/musd.ts index 0cd980bb9fe..4bf87b92b10 100644 --- a/app/components/UI/Earn/constants/musd.ts +++ b/app/components/UI/Earn/constants/musd.ts @@ -21,6 +21,16 @@ export const MUSD_TOKEN_ADDRESS_BY_CHAIN: Record = { [CHAIN_IDS.BSC]: '0xaca92e438df0b2401ff60da7e4337b687a2435da', }; +/** + * Check if the given token address is mUSD. + * mUSD has the same address on all supported chains. + */ +export const isMusdToken = (address?: string): boolean => { + if (!address) return false; + const musdAddress = MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.MAINNET]; + return address.toLowerCase() === musdAddress.toLowerCase(); +}; + /** * Chains where mUSD CTA should show (buy routes available). * BSC is excluded as buy routes are not yet available. diff --git a/app/components/UI/Earn/hooks/useMusdBalance.test.ts b/app/components/UI/Earn/hooks/useMusdBalance.test.ts index d7668dbf752..42d5b0f2458 100644 --- a/app/components/UI/Earn/hooks/useMusdBalance.test.ts +++ b/app/components/UI/Earn/hooks/useMusdBalance.test.ts @@ -4,17 +4,57 @@ import { Hex } from '@metamask/utils'; import { CHAIN_IDS } from '@metamask/transaction-controller'; import { useMusdBalance } from './useMusdBalance'; import { MUSD_TOKEN_ADDRESS_BY_CHAIN } from '../constants/musd'; +import { selectTokensBalances } from '../../../../selectors/tokenBalancesController'; +import { selectSelectedInternalAccountByScope } from '../../../../selectors/multichainAccounts/accounts'; +import { EVM_SCOPE } from '../constants/networks'; +import { RootState } from '../../../../reducers'; +import { InternalAccount } from '@metamask/keyring-internal-api'; jest.mock('react-redux'); +jest.mock('../../../../selectors/multichainAccounts/accounts'); const mockUseSelector = useSelector as jest.MockedFunction; +const mockSelectSelectedInternalAccountByScope = + selectSelectedInternalAccountByScope as jest.MockedFunction< + typeof selectSelectedInternalAccountByScope + >; + +type TokenBalancesByAddress = Record>>; describe('useMusdBalance', () => { const MUSD_ADDRESS = MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.MAINNET]; + const MOCK_EVM_ADDRESS = '0x0000000000000000000000000000000000000abc' as Hex; + const mockState = {} as RootState; + + let selectedEvmAddress: Hex | undefined; + let tokenBalancesByAddress: TokenBalancesByAddress; beforeEach(() => { jest.clearAllMocks(); - mockUseSelector.mockReturnValue({}); + selectedEvmAddress = MOCK_EVM_ADDRESS; + tokenBalancesByAddress = {} as TokenBalancesByAddress; + + mockSelectSelectedInternalAccountByScope.mockImplementation( + (_state: RootState) => (scope) => { + if (scope !== EVM_SCOPE || !selectedEvmAddress) { + return undefined; + } + + return { address: selectedEvmAddress } as unknown as InternalAccount; + }, + ); + + mockUseSelector.mockImplementation((selector) => { + if (selector === selectTokensBalances) { + return tokenBalancesByAddress; + } + + if (typeof selector === 'function') { + return selector(mockState); + } + + return undefined; + }); }); afterEach(() => { @@ -44,7 +84,9 @@ describe('useMusdBalance', () => { describe('balance detection', () => { it('returns hasMusdBalanceOnAnyChain false when no balances exist', () => { - mockUseSelector.mockReturnValue({}); + tokenBalancesByAddress = { + [MOCK_EVM_ADDRESS]: {}, + }; const { result } = renderHook(() => useMusdBalance()); @@ -53,11 +95,13 @@ describe('useMusdBalance', () => { }); it('returns hasMusdBalanceOnAnyChain false when MUSD balance is 0x0', () => { - mockUseSelector.mockReturnValue({ - [CHAIN_IDS.MAINNET]: { - [MUSD_ADDRESS]: '0x0', + tokenBalancesByAddress = { + [MOCK_EVM_ADDRESS]: { + [CHAIN_IDS.MAINNET]: { + [MUSD_ADDRESS]: '0x0', + }, }, - }); + }; const { result } = renderHook(() => useMusdBalance()); @@ -67,11 +111,13 @@ describe('useMusdBalance', () => { it('returns hasMusdBalanceOnAnyChain true when MUSD balance exists on mainnet', () => { const balance = '0x1234'; - mockUseSelector.mockReturnValue({ - [CHAIN_IDS.MAINNET]: { - [MUSD_ADDRESS]: balance, + tokenBalancesByAddress = { + [MOCK_EVM_ADDRESS]: { + [CHAIN_IDS.MAINNET]: { + [MUSD_ADDRESS]: balance, + }, }, - }); + }; const { result } = renderHook(() => useMusdBalance()); @@ -85,11 +131,13 @@ describe('useMusdBalance', () => { const lineaMusdAddress = MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.LINEA_MAINNET]; const balance = '0x5678'; - mockUseSelector.mockReturnValue({ - [CHAIN_IDS.LINEA_MAINNET]: { - [lineaMusdAddress]: balance, + tokenBalancesByAddress = { + [MOCK_EVM_ADDRESS]: { + [CHAIN_IDS.LINEA_MAINNET]: { + [lineaMusdAddress]: balance, + }, }, - }); + }; const { result } = renderHook(() => useMusdBalance()); @@ -102,11 +150,13 @@ describe('useMusdBalance', () => { it('returns hasMusdBalanceOnAnyChain true when MUSD balance exists on BSC', () => { const bscMusdAddress = MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.BSC]; const balance = '0x9abc'; - mockUseSelector.mockReturnValue({ - [CHAIN_IDS.BSC]: { - [bscMusdAddress]: balance, + tokenBalancesByAddress = { + [MOCK_EVM_ADDRESS]: { + [CHAIN_IDS.BSC]: { + [bscMusdAddress]: balance, + }, }, - }); + }; const { result } = renderHook(() => useMusdBalance()); @@ -119,14 +169,17 @@ describe('useMusdBalance', () => { it('returns balances from multiple chains', () => { const mainnetBalance = '0x1111'; const lineaBalance = '0x2222'; - mockUseSelector.mockReturnValue({ - [CHAIN_IDS.MAINNET]: { - [MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.MAINNET]]: mainnetBalance, + tokenBalancesByAddress = { + [MOCK_EVM_ADDRESS]: { + [CHAIN_IDS.MAINNET]: { + [MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.MAINNET]]: mainnetBalance, + }, + [CHAIN_IDS.LINEA_MAINNET]: { + [MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.LINEA_MAINNET]]: + lineaBalance, + }, }, - [CHAIN_IDS.LINEA_MAINNET]: { - [MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.LINEA_MAINNET]]: lineaBalance, - }, - }); + }; const { result } = renderHook(() => useMusdBalance()); @@ -141,11 +194,13 @@ describe('useMusdBalance', () => { describe('address case handling', () => { it('handles lowercase token address in balances', () => { const balance = '0x1234'; - mockUseSelector.mockReturnValue({ - [CHAIN_IDS.MAINNET]: { - [MUSD_ADDRESS.toLowerCase()]: balance, + tokenBalancesByAddress = { + [MOCK_EVM_ADDRESS]: { + [CHAIN_IDS.MAINNET]: { + [MUSD_ADDRESS.toLowerCase()]: balance, + }, }, - }); + }; const { result } = renderHook(() => useMusdBalance()); @@ -155,11 +210,13 @@ describe('useMusdBalance', () => { it('handles checksummed token address in balances', () => { const balance = '0x1234'; // MUSD_ADDRESS is already lowercase in the constant - mockUseSelector.mockReturnValue({ - [CHAIN_IDS.MAINNET]: { - [MUSD_ADDRESS]: balance, + tokenBalancesByAddress = { + [MOCK_EVM_ADDRESS]: { + [CHAIN_IDS.MAINNET]: { + [MUSD_ADDRESS]: balance, + }, }, - }); + }; const { result } = renderHook(() => useMusdBalance()); @@ -168,14 +225,32 @@ describe('useMusdBalance', () => { }); describe('edge cases', () => { + it('returns empty balances when selected EVM address is undefined', () => { + selectedEvmAddress = undefined; + tokenBalancesByAddress = { + [MOCK_EVM_ADDRESS]: { + [CHAIN_IDS.MAINNET]: { + [MUSD_ADDRESS]: '0x1234', + }, + }, + }; + + const { result } = renderHook(() => useMusdBalance()); + + expect(result.current.hasMusdBalanceOnAnyChain).toBe(false); + expect(result.current.balancesByChain).toEqual({}); + }); + it('ignores non-MUSD tokens on supported chains', () => { const otherTokenAddress = '0x1234567890abcdef1234567890abcdef12345678' as Hex; - mockUseSelector.mockReturnValue({ - [CHAIN_IDS.MAINNET]: { - [otherTokenAddress]: '0x9999', + tokenBalancesByAddress = { + [MOCK_EVM_ADDRESS]: { + [CHAIN_IDS.MAINNET]: { + [otherTokenAddress]: '0x9999', + }, }, - }); + }; const { result } = renderHook(() => useMusdBalance()); @@ -185,11 +260,13 @@ describe('useMusdBalance', () => { it('ignores MUSD-like tokens on unsupported chains', () => { const polygonChainId = '0x89' as Hex; - mockUseSelector.mockReturnValue({ - [polygonChainId]: { - [MUSD_ADDRESS]: '0x1234', + tokenBalancesByAddress = { + [MOCK_EVM_ADDRESS]: { + [polygonChainId]: { + [MUSD_ADDRESS]: '0x1234', + }, }, - }); + }; const { result } = renderHook(() => useMusdBalance()); @@ -199,14 +276,16 @@ describe('useMusdBalance', () => { it('handles mixed zero and non-zero balances correctly', () => { const balance = '0x1234'; - mockUseSelector.mockReturnValue({ - [CHAIN_IDS.MAINNET]: { - [MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.MAINNET]]: '0x0', - }, - [CHAIN_IDS.LINEA_MAINNET]: { - [MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.LINEA_MAINNET]]: balance, + tokenBalancesByAddress = { + [MOCK_EVM_ADDRESS]: { + [CHAIN_IDS.MAINNET]: { + [MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.MAINNET]]: '0x0', + }, + [CHAIN_IDS.LINEA_MAINNET]: { + [MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.LINEA_MAINNET]]: balance, + }, }, - }); + }; const { result } = renderHook(() => useMusdBalance()); @@ -217,9 +296,14 @@ describe('useMusdBalance', () => { }); it('handles undefined chain balances gracefully', () => { - mockUseSelector.mockReturnValue({ - [CHAIN_IDS.MAINNET]: undefined, - }); + tokenBalancesByAddress = { + [MOCK_EVM_ADDRESS]: { + [CHAIN_IDS.MAINNET]: undefined as unknown as Record< + Hex, + Record + >, + }, + } as unknown as TokenBalancesByAddress; const { result } = renderHook(() => useMusdBalance()); diff --git a/app/components/UI/Earn/hooks/useMusdBalance.ts b/app/components/UI/Earn/hooks/useMusdBalance.ts index d0bdff37168..5123af45ffd 100644 --- a/app/components/UI/Earn/hooks/useMusdBalance.ts +++ b/app/components/UI/Earn/hooks/useMusdBalance.ts @@ -1,16 +1,32 @@ import { useSelector } from 'react-redux'; import { useCallback, useMemo } from 'react'; import { Hex } from '@metamask/utils'; -import { selectContractBalancesPerChainId } from '../../../../selectors/tokenBalancesController'; +import { selectTokensBalances } from '../../../../selectors/tokenBalancesController'; import { MUSD_TOKEN_ADDRESS_BY_CHAIN } from '../constants/musd'; import { toChecksumAddress } from '../../../../util/address'; +import { selectSelectedInternalAccountByScope } from '../../../../selectors/multichainAccounts/accounts'; +import { EVM_SCOPE } from '../constants/networks'; +import { RootState } from '../../../../reducers'; /** * Hook to check if the user has any MUSD token balance across supported chains. * @returns Object containing hasAnyMusdBalance boolean and balancesByChain for detailed balance info */ export const useMusdBalance = () => { - const balancesPerChainId = useSelector(selectContractBalancesPerChainId); + const selectedEvmAddress = useSelector( + (state: RootState) => + selectSelectedInternalAccountByScope(state)(EVM_SCOPE)?.address, + ); + + const tokenBalances = useSelector(selectTokensBalances); + + const balancesPerChainId = useMemo( + () => + selectedEvmAddress + ? (tokenBalances?.[selectedEvmAddress as Hex] ?? {}) + : {}, + [selectedEvmAddress, tokenBalances], + ); const { hasMusdBalanceOnAnyChain, balancesByChain } = useMemo(() => { const result: Record = {}; diff --git a/app/components/UI/Earn/hooks/useMusdConversionFlowData.ts b/app/components/UI/Earn/hooks/useMusdConversionFlowData.ts index 9f22f273519..4f05202a653 100644 --- a/app/components/UI/Earn/hooks/useMusdConversionFlowData.ts +++ b/app/components/UI/Earn/hooks/useMusdConversionFlowData.ts @@ -1,7 +1,6 @@ import { useCallback, useMemo } from 'react'; import { Hex, KnownCaipNamespace } from '@metamask/utils'; import { useSelector } from 'react-redux'; -import { toHex } from '@metamask/controller-utils'; import { AssetType } from '../../../Views/confirmations/types/token'; import { useMusdConversionTokens } from './useMusdConversionTokens'; import { useMusdConversionEligibility } from './useMusdConversionEligibility'; @@ -14,6 +13,7 @@ import { import { selectAccountGroupBalanceForEmptyState } from '../../../../selectors/assets/balances'; import { MUSD_CONVERSION_DEFAULT_CHAIN_ID } from '../constants/musd'; import { toChecksumAddress } from '../../../../util/address'; +import { safeFormatChainIdToHex } from '../../Card/util/safeFormatChainIdToHex'; export interface MusdConversionFlowData { isPopularNetworksFilterActive: boolean; @@ -102,16 +102,25 @@ export const useMusdConversionFlowData = (): MusdConversionFlowData => { // If specific chain selected, find token on that chain; otherwise use first token const paymentToken = selectedChainId - ? conversionTokens.find((token) => token.chainId === selectedChainId) + ? conversionTokens.find( + (token) => + token.chainId && + safeFormatChainIdToHex(token.chainId) === selectedChainId, + ) : conversionTokens[0]; if (!paymentToken?.chainId || !paymentToken?.address) { return null; } + const paymentTokenChainIdHex = safeFormatChainIdToHex(paymentToken.chainId); + if (!paymentTokenChainIdHex.startsWith('0x')) { + return null; + } + return { address: toChecksumAddress(paymentToken.address), - chainId: toHex(paymentToken.chainId), + chainId: paymentTokenChainIdHex as Hex, }; }, [conversionTokens, selectedChainId]); diff --git a/app/components/UI/Earn/hooks/useMusdConversionTokens.ts b/app/components/UI/Earn/hooks/useMusdConversionTokens.ts index b02ad061334..1711c1f2252 100644 --- a/app/components/UI/Earn/hooks/useMusdConversionTokens.ts +++ b/app/components/UI/Earn/hooks/useMusdConversionTokens.ts @@ -12,7 +12,19 @@ import { TokenI } from '../../Tokens/types'; import { MUSD_TOKEN_ADDRESS_BY_CHAIN } from '../constants/musd'; import { toHex } from '@metamask/controller-utils'; import { BigNumber } from 'bignumber.js'; +import { Hex } from '@metamask/utils'; +import { safeFormatChainIdToHex } from '../../Card/util/safeFormatChainIdToHex'; +/** + * The source of truth for the tokens that are eligible for mUSD conversion. + * + * @returns Object containing: + * - filterAllowedTokens(tokens: AssetType[]): AssetType[] - Filters tokens based on allowlist and blocklist rules. + * - isConversionToken(token: AssetType | TokenI): boolean - Checks if a token is eligible for mUSD conversion. + * - isMusdSupportedOnChain(chainId: Hex): boolean - Checks if mUSD is supported on a given chain. + * - hasConvertibleTokensByChainId(chainId: Hex): boolean - Checks if there are convertible tokens on a given chain. + * - tokens: AssetType[] - The tokens that are eligible for mUSD conversion. + */ export const useMusdConversionTokens = () => { const musdConversionPaymentTokensAllowlist = useSelector( selectMusdConversionPaymentTokensAllowlist, @@ -81,13 +93,29 @@ export const useMusdConversionTokens = () => { [allTokens, filterAllowedTokens], ); + const hasConvertibleTokensByChainId = useCallback( + (chainId: Hex) => + conversionTokens.some( + (token) => + token.chainId && safeFormatChainIdToHex(token.chainId) === chainId, + ), + [conversionTokens], + ); + const isConversionToken = (token?: AssetType | TokenI) => { if (!token) return false; + if (!token.chainId) { + return false; + } + + const tokenChainId = safeFormatChainIdToHex(token.chainId); + return conversionTokens.some( (musdToken) => token.address.toLowerCase() === musdToken.address.toLowerCase() && - token.chainId === musdToken.chainId, + musdToken.chainId && + safeFormatChainIdToHex(musdToken.chainId) === tokenChainId, ); }; @@ -100,6 +128,7 @@ export const useMusdConversionTokens = () => { filterAllowedTokens, isConversionToken, isMusdSupportedOnChain, + hasConvertibleTokensByChainId, tokens: conversionTokens, }; }; diff --git a/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts b/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts index a0e599ee8cd..920cbc478d4 100644 --- a/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts +++ b/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts @@ -1,7 +1,10 @@ import { renderHook } from '@testing-library/react-hooks'; import { Hex } from '@metamask/utils'; import { CHAIN_IDS } from '@metamask/transaction-controller'; -import { useMusdCtaVisibility } from './useMusdCtaVisibility'; +import { + BUY_GET_MUSD_CTA_VARIANT, + useMusdCtaVisibility, +} from './useMusdCtaVisibility'; import { useMusdBalance } from './useMusdBalance'; import { useMusdConversionTokens } from './useMusdConversionTokens'; import { useMusdConversionEligibility } from './useMusdConversionEligibility'; @@ -183,6 +186,7 @@ describe('useMusdCtaVisibility', () => { filterAllowedTokens: jest.fn(), isConversionToken: jest.fn(), isMusdSupportedOnChain: jest.fn(), + hasConvertibleTokensByChainId: jest.fn().mockReturnValue(false), }); mockUseMusdConversionEligibility.mockReturnValue({ isEligible: true, @@ -581,6 +585,165 @@ describe('useMusdCtaVisibility', () => { }); }); + describe('get mUSD CTA (single selected chain)', () => { + const mainnetConversionToken: AssetType = { + chainId: CHAIN_IDS.MAINNET, + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + balance: '1000000', + balanceFiat: '1.00', + aggregators: [], + image: '', + logo: '', + isETH: false, + }; + + beforeEach(() => { + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: false, + }); + + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [{ chainId: CHAIN_IDS.MAINNET, enabled: true }], + }); + + // Non-empty wallet ensures we don't accidentally show the BUY CTA + mockAccountBalance = { + walletId: 'test-wallet', + groupId: 'test-group', + totalBalanceInUserCurrency: 100, + userCurrency: 'USD', + }; + }); + + it('returns GET variant with network icon when selected chain has convertible tokens and no mUSD balance on that chain', () => { + mockUseMusdConversionTokens.mockReturnValue({ + tokens: [mainnetConversionToken], + filterAllowedTokens: jest.fn(), + isConversionToken: jest.fn(), + isMusdSupportedOnChain: jest.fn(), + hasConvertibleTokensByChainId: jest.fn((chainId: Hex) => + Boolean(chainId === CHAIN_IDS.MAINNET), + ), + }); + + mockUseMusdBalance.mockReturnValue({ + hasMusdBalanceOnAnyChain: false, + balancesByChain: {}, + hasMusdBalanceOnChain: jest.fn().mockReturnValue(false), + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + const ctaState = result.current.shouldShowBuyGetMusdCta(); + + expect(ctaState).toMatchObject({ + shouldShowCta: true, + showNetworkIcon: true, + selectedChainId: CHAIN_IDS.MAINNET, + isEmptyWallet: false, + variant: BUY_GET_MUSD_CTA_VARIANT.GET, + }); + }); + + it('hides GET variant when selected chain has no convertible tokens', () => { + const lineaOnlyConversionToken: AssetType = { + ...mainnetConversionToken, + chainId: CHAIN_IDS.LINEA_MAINNET, + address: '0x176211869ca2b568f2a7d4ee941e073a821ee1ff', + }; + + mockUseMusdConversionTokens.mockReturnValue({ + tokens: [lineaOnlyConversionToken], + filterAllowedTokens: jest.fn(), + isConversionToken: jest.fn(), + isMusdSupportedOnChain: jest.fn(), + hasConvertibleTokensByChainId: jest.fn((chainId: Hex) => + Boolean(chainId === CHAIN_IDS.LINEA_MAINNET), + ), + }); + + mockUseMusdBalance.mockReturnValue({ + hasMusdBalanceOnAnyChain: false, + balancesByChain: {}, + hasMusdBalanceOnChain: jest.fn().mockReturnValue(false), + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + const ctaState = result.current.shouldShowBuyGetMusdCta(); + + expect(ctaState).toMatchObject({ + shouldShowCta: false, + showNetworkIcon: false, + selectedChainId: null, + isEmptyWallet: false, + variant: null, + }); + }); + }); + + describe('buy mUSD CTA (single selected chain)', () => { + beforeEach(() => { + mockUseNetworksByCustomNamespace.mockReturnValue({ + ...defaultNetworksByNamespace, + areAllNetworksSelected: false, + }); + + mockUseCurrentNetworkInfo.mockReturnValue({ + ...defaultNetworkInfo, + enabledNetworks: [{ chainId: CHAIN_IDS.MAINNET, enabled: true }], + }); + + // Empty wallet is required for the BUY variant to be considered. + mockAccountBalance = { + walletId: 'test-wallet', + groupId: 'test-group', + totalBalanceInUserCurrency: 0, + userCurrency: 'USD', + }; + }); + + it('hides BUY variant when selected chain is not buyable, even when wallet is empty', () => { + // Ensure GET variant cannot be shown (no convertible tokens). + mockUseMusdConversionTokens.mockReturnValue({ + tokens: [], + filterAllowedTokens: jest.fn(), + isConversionToken: jest.fn(), + isMusdSupportedOnChain: jest.fn(), + hasConvertibleTokensByChainId: jest.fn().mockReturnValue(false), + }); + + // Selected chain is not buyable on Ramp (no supported token route). + mockUseRampTokens.mockReturnValue({ + ...defaultRampTokens, + allTokens: [ + createMusdRampToken(CHAIN_IDS.MAINNET, false), // not buyable on selected chain + createMusdRampToken(CHAIN_IDS.LINEA_MAINNET, true), // buyable elsewhere + ], + }); + + mockUseMusdBalance.mockReturnValue({ + hasMusdBalanceOnAnyChain: false, + balancesByChain: {}, + hasMusdBalanceOnChain: jest.fn().mockReturnValue(false), + }); + + const { result } = renderHook(() => useMusdCtaVisibility()); + const ctaState = result.current.shouldShowBuyGetMusdCta(); + + expect(ctaState).toMatchObject({ + shouldShowCta: false, + showNetworkIcon: false, + selectedChainId: null, + isEmptyWallet: true, + variant: null, + }); + }); + }); + describe('unsupported network selected', () => { it('returns shouldShowCta false for Polygon', () => { const polygonChainId = '0x89' as Hex; @@ -856,11 +1019,14 @@ describe('useMusdCtaVisibility', () => { name: 'USDC', symbol: 'USDC', chainId: CHAIN_IDS.MAINNET, - }) as TokenI, + }) as AssetType, ], filterAllowedTokens: jest.fn(), isConversionToken: jest.fn().mockReturnValue(true), isMusdSupportedOnChain: jest.fn().mockReturnValue(true), + hasConvertibleTokensByChainId: jest.fn((chainId: Hex) => + Boolean(chainId === CHAIN_IDS.MAINNET), + ), }); const { result } = renderHook(() => useMusdCtaVisibility()); @@ -961,6 +1127,9 @@ describe('useMusdCtaVisibility', () => { filterAllowedTokens: jest.fn(), isConversionToken: jest.fn(), isMusdSupportedOnChain: jest.fn(), + hasConvertibleTokensByChainId: jest.fn((chainId: Hex) => + Boolean(chainId === mockConversionToken.chainId), + ), }); const { result } = renderHook(() => useMusdCtaVisibility()); @@ -983,6 +1152,7 @@ describe('useMusdCtaVisibility', () => { filterAllowedTokens: jest.fn(), isConversionToken: jest.fn(), isMusdSupportedOnChain: jest.fn(), + hasConvertibleTokensByChainId: jest.fn().mockReturnValue(false), }); const { result } = renderHook(() => useMusdCtaVisibility()); @@ -1035,6 +1205,9 @@ describe('useMusdCtaVisibility', () => { filterAllowedTokens: jest.fn(), isConversionToken: jest.fn(), isMusdSupportedOnChain: jest.fn(), + hasConvertibleTokensByChainId: jest.fn((chainId: Hex) => + Boolean(chainId === conversionToken.chainId), + ), }); mockMusdConversionCtaTokens = { [CHAIN_IDS.MAINNET]: ['USDC'] }; @@ -1332,6 +1505,9 @@ describe('useMusdCtaVisibility', () => { filterAllowedTokens: jest.fn(), isConversionToken: jest.fn(), isMusdSupportedOnChain: jest.fn(), + hasConvertibleTokensByChainId: jest.fn((chainId: Hex) => + Boolean(chainId === conversionToken.chainId), + ), }); mockMusdConversionCtaTokens = { [CHAIN_IDS.MAINNET]: ['USDC'] }; }); diff --git a/app/components/UI/Earn/hooks/useMusdCtaVisibility.ts b/app/components/UI/Earn/hooks/useMusdCtaVisibility.ts index 01f1062e46a..38d657c0756 100644 --- a/app/components/UI/Earn/hooks/useMusdCtaVisibility.ts +++ b/app/components/UI/Earn/hooks/useMusdCtaVisibility.ts @@ -1,5 +1,5 @@ import { useCallback, useMemo } from 'react'; -import { add0x } from '@metamask/utils'; +import { add0x, Hex } from '@metamask/utils'; import { useSelector } from 'react-redux'; import { useMusdBalance } from './useMusdBalance'; import { @@ -17,6 +17,35 @@ import { isTokenInWildcardList } from '../utils/wildcardTokenList'; import { isNonEvmChainId } from '../../../../core/Multichain/utils'; import { useMusdConversionFlowData } from './useMusdConversionFlowData'; +/** + * Variant for the primary mUSD CTA. + * + * Consumers should treat this as the source of truth for: + * - CTA label ("Buy mUSD" vs "Get mUSD") + * - CTA behavior (Ramp buy flow vs conversion flow) + */ +export enum BUY_GET_MUSD_CTA_VARIANT { + BUY = 'buy', + GET = 'get', +} + +// Invariant: if `shouldShowCta === true`, then `variant !== null`. +export type BuyGetMusdCtaState = + | { + shouldShowCta: false; + showNetworkIcon: false; + selectedChainId: null; + isEmptyWallet: boolean; + variant: null; + } + | { + shouldShowCta: true; + showNetworkIcon: boolean; + selectedChainId: Hex | null; + isEmptyWallet: boolean; + variant: BUY_GET_MUSD_CTA_VARIANT; + }; + /** * Hook exposing helpers that decide whether to show various mUSD-related CTAs. * @@ -29,7 +58,7 @@ import { useMusdConversionFlowData } from './useMusdConversionFlowData'; * wallet balance state, and the configured wildcard token list for conversion CTAs. * * @returns Object containing: - * - shouldShowBuyGetMusdCta(): { shouldShowCta, showNetworkIcon, selectedChainId, isEmptyWallet } + * - shouldShowBuyGetMusdCta(): BuyGetMusdCtaState * - shouldShowTokenListItemCta(asset?): boolean * - shouldShowAssetOverviewCta(asset?): boolean */ @@ -59,7 +88,8 @@ export const useMusdCtaVisibility = () => { const { hasMusdBalanceOnAnyChain, hasMusdBalanceOnChain } = useMusdBalance(); - const { tokens: conversionTokens } = useMusdConversionTokens(); + const { tokens: conversionTokens, hasConvertibleTokensByChainId } = + useMusdConversionTokens(); const getConversionTokensWithCtas = useCallback( (tokens: AssetType[]) => @@ -90,23 +120,13 @@ export const useMusdCtaVisibility = () => { }, [tokensWithCTAs], ); - - /** - * Buy/Get mUSD CTA visibility logic. - * - * Shows the CTA when: - * - Feature flag is enabled AND - * - mUSD balance/buyability conditions are met AND - * - (wallet is empty OR user has convertible tokens) - * - * Returns isEmptyWallet for determining CTA text ("Buy" vs "Get") and action handling. - */ - const shouldShowBuyGetMusdCta = useCallback(() => { - const hiddenResult = { + const shouldShowBuyMusdCta = useCallback((): BuyGetMusdCtaState => { + const hiddenResult: BuyGetMusdCtaState = { shouldShowCta: false, showNetworkIcon: false, selectedChainId: null, isEmptyWallet, + variant: null, }; // If the buy/get mUSD CTA feature flag is disabled, don't show the buy/get mUSD CTA @@ -114,68 +134,164 @@ export const useMusdCtaVisibility = () => { return hiddenResult; } - // Don't show CTA if user has tokens but none are convertible - // (only show when wallet is empty OR has convertible tokens) - if (!isEmptyWallet && !hasConvertibleTokens) { + // If user is geo-blocked, don't show the CTA + if (!isGeoEligible) { return hiddenResult; } - // If user is geo-blocked, don't show the CTA - if (!isGeoEligible) { + // Only show if wallet is empty + if (!isEmptyWallet) { return hiddenResult; } - // If all networks are selected if (isPopularNetworksFilterActive) { - // Show the buy/get mUSD CTA without network icon if: - // - User doesn't have MUSD on any chain - // - AND mUSD is buyable on at least one chain in user's region + const shouldShowCta = + isMusdBuyableOnAnyChain && !hasMusdBalanceOnAnyChain; + + if (!shouldShowCta) { + return hiddenResult; + } + return { - shouldShowCta: !hasMusdBalanceOnAnyChain && isMusdBuyableOnAnyChain, + shouldShowCta: true, showNetworkIcon: false, selectedChainId: null, isEmptyWallet, + variant: BUY_GET_MUSD_CTA_VARIANT.BUY, }; } - // If exactly one chain is selected + // Specific network selected if (!selectedChainId) { return hiddenResult; } - const chainId = selectedChainId; + const isMusdBuyableOnSelectedChain = Boolean( + isMusdBuyableOnChain[selectedChainId], + ); - // Check if mUSD is buyable on this chain in user's region - // isMusdBuyableOnChain will be undefined/false for chains that don't support buying or aren't buyable in the region - const isMusdBuyableInRegion = isMusdBuyableOnChain[chainId] ?? false; - - if (!isMusdBuyableInRegion) { - // Chain doesn't have buy routes available OR mUSD not buyable in user's region - hide the buy/get mUSD CTA - return hiddenResult; + if ( + isMusdBuyableOnSelectedChain && + !hasMusdBalanceOnChain(selectedChainId) + ) { + return { + shouldShowCta: true, + showNetworkIcon: true, + selectedChainId, + isEmptyWallet, + variant: BUY_GET_MUSD_CTA_VARIANT.BUY, + }; } - // Supported chain selected - check if user has MUSD on this specific chain - const hasMusdOnSelectedChain = hasMusdBalanceOnChain(chainId); + // mUSD not buyable on selected chain + return hiddenResult; + }, [ + hasMusdBalanceOnAnyChain, + hasMusdBalanceOnChain, + isEmptyWallet, + isGeoEligible, + isMusdBuyableOnAnyChain, + isMusdBuyableOnChain, + isMusdGetBuyCtaEnabled, + isPopularNetworksFilterActive, + selectedChainId, + ]); - return { - shouldShowCta: !hasMusdOnSelectedChain, - showNetworkIcon: true, - selectedChainId: chainId, + const shouldShowGetMusdCta = useCallback((): BuyGetMusdCtaState => { + const hiddenResult: BuyGetMusdCtaState = { + shouldShowCta: false, + showNetworkIcon: false, + selectedChainId: null, isEmptyWallet, + variant: null, }; + + // If the buy/get mUSD CTA feature flag is disabled, don't show the buy/get mUSD CTA + if (!isMusdGetBuyCtaEnabled) { + return hiddenResult; + } + + // If user is geo-blocked, don't show the CTA + if (!isGeoEligible) { + return hiddenResult; + } + + // Can't enter conversion flow if user doesn't have supported tokens to convert. + if (!hasConvertibleTokens) { + return hiddenResult; + } + + if ( + isPopularNetworksFilterActive && + // When user has mUSD we show the secondary (token list item) CTA. + !hasMusdBalanceOnAnyChain + ) { + return { + shouldShowCta: true, + showNetworkIcon: false, + selectedChainId: null, + isEmptyWallet, + variant: BUY_GET_MUSD_CTA_VARIANT.GET, + }; + } + + if (!selectedChainId) { + return hiddenResult; + } + + const hasMusdBalanceOnSelectedChain = + hasMusdBalanceOnChain(selectedChainId); + + if ( + hasConvertibleTokensByChainId(selectedChainId) && + !hasMusdBalanceOnSelectedChain + ) { + return { + shouldShowCta: true, + showNetworkIcon: true, + selectedChainId, + isEmptyWallet, + variant: BUY_GET_MUSD_CTA_VARIANT.GET, + }; + } + + return hiddenResult; }, [ hasConvertibleTokens, + hasConvertibleTokensByChainId, hasMusdBalanceOnAnyChain, hasMusdBalanceOnChain, - isGeoEligible, isEmptyWallet, - isMusdBuyableOnAnyChain, - isMusdBuyableOnChain, + isGeoEligible, isMusdGetBuyCtaEnabled, isPopularNetworksFilterActive, selectedChainId, ]); + /** + * Buy/Get mUSD CTA visibility logic. + * + * Resolves the primary mUSD CTA into a single visible variant (or hidden). + * + * - **Get**: shown when the user has convertible tokens for the current network view and does not hold mUSD (favored when both variants could apply). + * - **Buy**: shown when the wallet is empty, mUSD is buyable for the current network view, and the user does not hold mUSD. + * + * Consumers should use the returned `variant` to determine CTA label + action. + */ + const shouldShowBuyGetMusdCta = useCallback((): BuyGetMusdCtaState => { + const hiddenDefault: BuyGetMusdCtaState = { + shouldShowCta: false, + showNetworkIcon: false, + selectedChainId: null, + isEmptyWallet, + variant: null, + }; + + // Note: The order of the cta variants is important. We want to favour the get mUSD CTA over the buy mUSD CTA. + const ctaVariants = [shouldShowGetMusdCta(), shouldShowBuyMusdCta()]; + return ctaVariants.find((cta) => cta.shouldShowCta) ?? hiddenDefault; + }, [isEmptyWallet, shouldShowBuyMusdCta, shouldShowGetMusdCta]); + const shouldShowTokenListItemCta = useCallback( (asset?: TokenI) => { if (!isMusdConversionTokenListItemCtaEnabled || !asset?.chainId) { diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js index 424dfc0e44c..66e40f485b9 100644 --- a/app/components/UI/Navbar/index.js +++ b/app/components/UI/Navbar/index.js @@ -45,7 +45,7 @@ import { SettingsViewSelectorsIDs } from '../../Views/Settings/SettingsView.test import HeaderBase, { HeaderBaseVariant, } from '../../../component-library/components/HeaderBase'; -import getHeaderCenterNavbarOptions from '../../../component-library/components-temp/HeaderCenter/getHeaderCenterNavbarOptions'; +import getHeaderCompactStandardNavbarOptions from '../../../component-library/components-temp/HeaderCompactStandard/getHeaderCompactStandardNavbarOptions'; import BottomSheetHeader from '../../../component-library/components/BottomSheets/BottomSheetHeader'; import AvatarToken from '../../../component-library/components/Avatars/Avatar/variants/AvatarToken'; import { AvatarSize } from '../../../component-library/components/Avatars/Avatar'; @@ -1578,7 +1578,7 @@ export function getBridgeNavbar(navigation, bridgeViewMode, themeColors) { title = strings('swaps.title'); } - return getHeaderCenterNavbarOptions({ + return getHeaderCompactStandardNavbarOptions({ title, onClose: () => navigation.dangerouslyGetParent()?.pop(), includesTopInset: true, @@ -1715,7 +1715,7 @@ export function getDepositNavbarOptions( }; } - return getHeaderCenterNavbarOptions({ + return getHeaderCompactStandardNavbarOptions({ title, startButtonIconProps, closeButtonProps, diff --git a/app/components/UI/NetworkManager/index.tsx b/app/components/UI/NetworkManager/index.tsx index 8e0b5578bc4..5c7a712f0e9 100644 --- a/app/components/UI/NetworkManager/index.tsx +++ b/app/components/UI/NetworkManager/index.tsx @@ -17,7 +17,7 @@ import { useTheme } from '../../../util/theme'; import { MetaMetricsEvents, useMetrics } from '../../hooks/useMetrics'; import { strings } from '../../../../locales/i18n'; import BottomSheetHeader from '../../../component-library/components/BottomSheets/BottomSheetHeader/BottomSheetHeader'; -import HeaderCenter from '../../../component-library/components-temp/HeaderCenter'; +import HeaderCompactStandard from '../../../component-library/components-temp/HeaderCompactStandard'; import BottomSheetFooter from '../../../component-library/components/BottomSheets/BottomSheetFooter/BottomSheetFooter'; import { ButtonsAlignment } from '../../../component-library/components/BottomSheets/BottomSheetFooter'; import { ButtonProps } from '../../../component-library/components/Buttons/Button/Button.types'; @@ -350,7 +350,7 @@ const NetworkManager = () => { shouldNavigateBack > - sheetRef.current?.onCloseBottomSheet()} /> diff --git a/app/components/UI/NetworkModal/__snapshots__/index.test.tsx.snap b/app/components/UI/NetworkModal/__snapshots__/index.test.tsx.snap index e8f1953b54c..36a9d657f9b 100644 --- a/app/components/UI/NetworkModal/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/NetworkModal/__snapshots__/index.test.tsx.snap @@ -490,7 +490,7 @@ exports[`NetworkDetails renders correctly 1`] = ` style={ { "flexDirection": "row", - "paddingHorizontal": 8, + "paddingHorizontal": 16, "paddingVertical": 4, } } diff --git a/app/components/UI/NetworkVerificationInfo/NetworkVerificationInfo.styles.ts b/app/components/UI/NetworkVerificationInfo/NetworkVerificationInfo.styles.ts index 11d62d88c57..60e55de48a3 100644 --- a/app/components/UI/NetworkVerificationInfo/NetworkVerificationInfo.styles.ts +++ b/app/components/UI/NetworkVerificationInfo/NetworkVerificationInfo.styles.ts @@ -86,6 +86,9 @@ const styleSheet = (params: { theme: Theme }) => { headerStyle: { width: '100%', }, + footerPadding: { + paddingHorizontal: 16, + }, }); }; export default styleSheet; diff --git a/app/components/UI/NetworkVerificationInfo/NetworkVerificationInfo.tsx b/app/components/UI/NetworkVerificationInfo/NetworkVerificationInfo.tsx index 8b33030a67a..d93a08855dc 100644 --- a/app/components/UI/NetworkVerificationInfo/NetworkVerificationInfo.tsx +++ b/app/components/UI/NetworkVerificationInfo/NetworkVerificationInfo.tsx @@ -373,6 +373,7 @@ const NetworkVerificationInfo = ({ { return ( {/* Header */} - ) : ( - validationContainer: { marginBottom: 12, }, + insufficientPayTokenWarning: { + backgroundColor: colors.warning.muted, + paddingHorizontal: 12, + paddingVertical: 10, + borderRadius: 8, + marginTop: 12, + }, bottomSection: { paddingVertical: 24, }, diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx index 6229df5fdab..350bfc54d28 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx @@ -230,6 +230,7 @@ jest.mock('../../hooks', () => ({ handleMaxAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: 11, positionSize: 0.0037, @@ -737,6 +738,7 @@ const defaultMockHooks = { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, }, }; @@ -924,6 +926,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '11', positionSize: '0.0037', @@ -1020,6 +1023,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '11', positionSize: '0.0037', @@ -1227,6 +1231,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '11', positionSize: '0.0037', @@ -1273,6 +1278,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '11', positionSize: '0.0037', @@ -1585,6 +1591,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '10', positionSize: '0.033', @@ -1637,6 +1644,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '10', positionSize: '0.033', @@ -1689,6 +1697,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '10', positionSize: '0.033', @@ -1744,6 +1753,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '10', positionSize: '0.033', @@ -1799,6 +1809,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '10', positionSize: '0.033', @@ -1853,6 +1864,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '10', positionSize: '0.033', @@ -1932,6 +1944,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '33.33', positionSize: '0.0333', @@ -1962,6 +1975,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '33.33', positionSize: '0.0333', @@ -2071,6 +2085,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '33.33', positionSize: '0.0333', @@ -2101,6 +2116,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '33.33', positionSize: '0.0333', @@ -2448,6 +2464,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '0', positionSize: '0', @@ -2488,6 +2505,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '33.33', positionSize: '0.0333', @@ -2608,6 +2626,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '20.00', // Truthy value - triggers formatPrice path positionSize: '0.02', @@ -2647,6 +2666,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '', // Falsy value - triggers fallback path positionSize: '', @@ -2686,6 +2706,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '16.67', positionSize: '0.0167', @@ -2728,6 +2749,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '0', positionSize: '0', @@ -2780,6 +2802,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '6.25', positionSize: '0.0083', @@ -2911,6 +2934,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '11', positionSize: '0.0037', @@ -3020,6 +3044,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '11', positionSize: '0.0002', diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index 3baf906ac42..82efdc312bb 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -40,12 +40,14 @@ import ListItem from '../../../../../component-library/components/List/ListItem' import ListItemColumn, { WidthType, } from '../../../../../component-library/components/List/ListItemColumn'; +import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; import Text, { TextColor, TextVariant, } from '../../../../../component-library/components/Texts/Text'; import useTooltipModal from '../../../../../components/hooks/useTooltipModal'; import Routes from '../../../../../constants/navigation/Routes'; +import DevLogger from '../../../../../core/SDKConnect/utils/DevLogger'; import { useTheme } from '../../../../../util/theme'; import { TraceName } from '../../../../../util/trace'; import Keypad from '../../../../Base/Keypad'; @@ -54,11 +56,16 @@ import { ARBITRUM_USDC, PERPS_CURRENCY, } from '../../../../Views/confirmations/constants/perps'; -import { useAddToken } from '../../../../Views/confirmations/hooks/tokens/useAddToken'; -import { useAutomaticTransactionPayToken } from '../../../../Views/confirmations/hooks/pay/useAutomaticTransactionPayToken'; +import { + useIsTransactionPayQuoteLoading, + useTransactionPayTotals, +} from '../../../../Views/confirmations/hooks/pay/useTransactionPayData'; import { useTransactionPayMetrics } from '../../../../Views/confirmations/hooks/pay/useTransactionPayMetrics'; +import { useTransactionPayToken } from '../../../../Views/confirmations/hooks/pay/useTransactionPayToken'; +import { useAddToken } from '../../../../Views/confirmations/hooks/tokens/useAddToken'; +import { useTransactionConfirm } from '../../../../Views/confirmations/hooks/transactions/useTransactionConfirm'; +import { useTransactionCustomAmount } from '../../../../Views/confirmations/hooks/transactions/useTransactionCustomAmount'; import { useTransactionMetadataRequest } from '../../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest'; -import useClearConfirmationOnBackSwipe from '../../../../Views/confirmations/hooks/ui/useClearConfirmationOnBackSwipe'; import AddRewardsAccount from '../../../Rewards/components/AddRewardsAccount/AddRewardsAccount'; import RewardsAnimations, { RewardAnimationState, @@ -113,16 +120,15 @@ import { usePerpsLivePrices, usePerpsTopOfBook, } from '../../hooks/stream'; +import { useIsPerpsBalanceSelected } from '../../hooks/useIsPerpsBalanceSelected'; import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement'; import { usePerpsOICap } from '../../hooks/usePerpsOICap'; -import { usePerpsPaymentTokens } from '../../hooks/usePerpsPaymentTokens'; import { usePerpsSavePendingConfig } from '../../hooks/usePerpsSavePendingConfig'; import { selectPerpsButtonColorTestVariant, selectPerpsTradeWithAnyTokenEnabledFlag, } from '../../selectors/featureFlags'; -import type { PerpsToken } from '../../types/perps-types'; import { BUTTON_COLOR_TEST } from '../../utils/abTesting/tests'; import { usePerpsABTest } from '../../utils/abTesting/usePerpsABTest'; import { @@ -140,13 +146,11 @@ import { calculateRoEForPrice, isStopLossSafeFromLiquidation, } from '../../utils/tpslValidation'; -import { PerpsDepositFees } from '../../../../Views/confirmations/components/info/external/perps'; import createStyles from './PerpsOrderView.styles'; import { PerpsPayRow } from './PerpsPayRow'; -import { useTransactionConfirm } from '../../../../Views/confirmations/hooks/transactions/useTransactionConfirm'; -import { useTransactionCustomAmount } from '../../../../Views/confirmations/hooks/transactions/useTransactionCustomAmount'; import { useUpdateTokenAmount } from '../../../../Views/confirmations/hooks/transactions/useUpdateTokenAmount'; -import DevLogger from '../../../../../core/SDKConnect/utils/DevLogger'; +import { useConfirmActions } from '../../../../Views/confirmations/hooks/useConfirmActions'; +import Engine from '../../../../../core/Engine'; // Navigation params interface interface OrderRouteParams { @@ -202,14 +206,24 @@ const PerpsOrderViewContentBase: React.FC = ({ tokenAddress: ARBITRUM_USDC.address, }); - useClearConfirmationOnBackSwipe(); + // Clear confirmation when leaving the order view + const { onReject } = useConfirmActions(); + useEffect( + () => () => { + onReject(undefined, true); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + // Reset selected payment token to Perps balance when leaving the order view + useEffect( + () => () => { + Engine.context.PerpsController?.resetSelectedPaymentToken?.(); + }, + [], + ); - // Disable automatic token selection - we want to show "Perps balance" by default - // User can explicitly select a token from the modal - useAutomaticTransactionPayToken({ - disable: true, // Always disable auto-selection to show "Perps balance" by default - preferredToken: undefined, - }); useTransactionPayMetrics(); const styles = createStyles(colors); @@ -235,12 +249,9 @@ const PerpsOrderViewContentBase: React.FC = ({ const [selectedTooltip, setSelectedTooltip] = useState(null); - // Track if user selected a custom token (not Perps balance) - const [hasCustomTokenSelected, setHasCustomTokenSelected] = useState(false); - - const handleCustomTokenSelected = useCallback(() => { - setHasCustomTokenSelected(true); - }, []); + const { payToken } = useTransactionPayToken(); + const isPayTokenPerpsBalance = useIsPerpsBalanceSelected(); + const hasCustomTokenSelected = !isPayTokenPerpsBalance; const { track } = usePerpsEventTracking(); const { openTooltipModal } = useTooltipModal(); @@ -263,12 +274,7 @@ const PerpsOrderViewContentBase: React.FC = ({ const { account, isInitialLoading: isLoadingAccount } = usePerpsLiveAccount(); - // Get real HyperLiquid USDC balance - const availableBalance = parseFloat( - account?.availableBalance?.toString() || '0', - ); - - // Get order form state from context instead of hook + // Get order form state from context; balanceForValidation respects custom token amount when set const { orderForm, setAmount, @@ -280,6 +286,7 @@ const PerpsOrderViewContentBase: React.FC = ({ handlePercentageAmount, handleMaxAmount, maxPossibleAmount, + balanceForValidation: availableBalance, // existingPosition is available in context but not used in this component } = usePerpsOrderContext(); @@ -352,18 +359,15 @@ const PerpsOrderViewContentBase: React.FC = ({ const [isOrderTypeVisible, setIsOrderTypeVisible] = useState(false); const [isInputFocused, setIsInputFocused] = useState(false); const [shouldOpenLimitPrice, setShouldOpenLimitPrice] = useState(false); - const [selectedToken, setSelectedToken] = useState( - undefined, - ); + const [depositAmount, setDepositAmount] = useState(''); - // Get available payment tokens and set default if none selected - const paymentTokens = usePerpsPaymentTokens(); - useEffect(() => { - if (!selectedToken && paymentTokens.length > 0) { - setSelectedToken(paymentTokens[0]); - } - }, [paymentTokens, selectedToken]); + const isPayRowVisible = Boolean( + isTradeWithAnyTokenEnabled && + depositAmount && + depositAmount.trim() !== '' && + activeTransactionMeta, + ); // Handle opening limit price modal after order type modal closes useEffect(() => { @@ -440,6 +444,28 @@ const PerpsOrderViewContentBase: React.FC = ({ const estimatedFees = feeResults.totalFee; + // Deposit/bridge fees from transaction pay (when paying with custom token) + const payTotals = useTransactionPayTotals(); + const isPayTotalsLoading = useIsTransactionPayQuoteLoading(); + const depositFeeUsd = useMemo(() => { + if (!hasCustomTokenSelected || !payTotals?.fees) return 0; + const { provider, sourceNetwork, targetNetwork } = payTotals.fees; + return new BigNumber(provider?.usd ?? 0) + .plus(sourceNetwork?.estimate?.usd ?? 0) + .plus(targetNetwork?.usd ?? 0) + .toNumber(); + }, [hasCustomTokenSelected, payTotals]); + + const combinedFees = useMemo( + () => estimatedFees + depositFeeUsd, + [estimatedFees, depositFeeUsd], + ); + + const feesToDisplay = hasCustomTokenSelected ? combinedFees : estimatedFees; + const isFeesLoading = + feeResults.isLoadingMetamaskFee || + (hasCustomTokenSelected && isPayTotalsLoading); + // Simple boolean calculation - no need for expensive memoization const hasValidAmount = parseFloat(orderForm.amount) > 0; @@ -508,6 +534,15 @@ const PerpsOrderViewContentBase: React.FC = ({ positionSize, ]); + const hasInsufficientPayTokenBalance = useMemo(() => { + if (marginRequired == null || !payToken || !hasCustomTokenSelected) { + return false; + } + const requiredUsd = Number(marginRequired); + const balanceUsd = Number(payToken.balanceUsd); + return requiredUsd > balanceUsd; + }, [hasCustomTokenSelected, marginRequired, payToken]); + const { updatePositionTPSL } = usePerpsTrading(); // Order execution using new hook @@ -774,7 +809,7 @@ const PerpsOrderViewContentBase: React.FC = ({ }; // Clamp amount to the maximum allowed once the keypad/input is dismissed - // Mirrors the PerpsClosePositionView behavior where values are normalized to valid limits + // maxPossibleAmount from context respects selected token amount in USD when paying with custom token useEffect(() => { if (!isInputFocused) { // Only clamp if input was from keypad (not from percentage/slider/max) @@ -825,6 +860,14 @@ const PerpsOrderViewContentBase: React.FC = ({ // Show deposit toast and set up tracking before confirming handleDepositConfirm(activeTransactionMeta, () => { + hasShownSubmittedToastRef.current = true; + showToast( + PerpsToastOptions.orderManagement[orderForm.type].submitted( + orderForm.direction, + positionSize, + orderForm.asset, + ), + ); handlePlaceOrder(true); }); @@ -1032,6 +1075,7 @@ const PerpsOrderViewContentBase: React.FC = ({ executeOrder, showToast, PerpsToastOptions.formValidation.orderForm, + PerpsToastOptions.orderManagement, PerpsToastOptions.positionManagement.tpsl, updatePositionTPSL, marginRequired, @@ -1189,9 +1233,9 @@ const PerpsOrderViewContentBase: React.FC = ({ = ({ {/* Combined TP/SL row - Hidden when modifying existing position */} {!hideTPSL && ( - + = ({ )} + {/* Pay with row - directly below TP/SL, same stacked box styling */} + {isPayRowVisible && ( + + handleTooltipPress('pay_with')} + /> + + )} + {hasInsufficientPayTokenBalance && ( + + + {strings( + 'perps.order.validation.insufficient_funds_to_cover_trade', + )} + + + )} {!hideTPSL && doesStopLossRiskLiquidation && ( @@ -1405,30 +1472,22 @@ const PerpsOrderViewContentBase: React.FC = ({ /> - - - - {isTradeWithAnyTokenEnabled && - depositAmount && - depositAmount.trim() !== '' && - activeTransactionMeta && ( - - - {hasCustomTokenSelected ? : null} - + {isFeesLoading ? ( + + ) : ( + )} + {/* Rewards Points Estimation */} {rewardsState.shouldShowRewardsRow && @@ -1560,7 +1619,8 @@ const PerpsOrderViewContentBase: React.FC = ({ !orderValidation.isValid || isPlacingOrder || doesStopLossRiskLiquidation || - isAtOICap + isAtOICap || + isFeesLoading } loading={isPlacingOrder} testID={PerpsOrderViewSelectorsIDs.PLACE_ORDER_BUTTON} @@ -1579,7 +1639,8 @@ const PerpsOrderViewContentBase: React.FC = ({ !orderValidation.isValid || isPlacingOrder || doesStopLossRiskLiquidation || - isAtOICap + isAtOICap || + isFeesLoading } isLoading={isPlacingOrder} testID={PerpsOrderViewSelectorsIDs.PLACE_ORDER_BUTTON} @@ -1702,6 +1763,12 @@ const PerpsOrderViewContentBase: React.FC = ({ protocolFeeRate: feeResults.protocolFeeRate, originalMetamaskFeeRate: feeResults.originalMetamaskFeeRate, feeDiscountPercentage: feeResults.feeDiscountPercentage, + ...(hasCustomTokenSelected && + depositFeeUsd > 0 && { + bridgeFeeFormatted: formatPerpsFiat(depositFeeUsd, { + ranges: PRICE_RANGES_MINIMAL_VIEW, + }), + }), } : undefined } @@ -1728,6 +1795,8 @@ PerpsOrderViewContent.displayName = 'PerpsOrderViewContent'; // Main component that wraps content with context providers const PerpsOrderView: React.FC = () => { const route = useRoute>(); + const { payToken } = useTransactionPayToken(); + const hasCustomTokenSelected = !useIsPerpsBalanceSelected(); // Get navigation params to pass to context provider const { @@ -1739,6 +1808,12 @@ const PerpsOrderView: React.FC = () => { hideTPSL = false, } = route.params || {}; + const effectiveAvailableBalance = useMemo(() => { + if (!hasCustomTokenSelected) return undefined; + const amount = payToken?.balanceUsd; + return amount !== undefined ? Number(amount) : undefined; + }, [hasCustomTokenSelected, payToken?.balanceUsd]); + return ( { initialAmount={paramAmount} initialLeverage={paramLeverage} existingPosition={existingPosition} + effectiveAvailableBalance={effectiveAvailableBalance} > diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.test.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.test.tsx new file mode 100644 index 00000000000..243cd1960c5 --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.test.tsx @@ -0,0 +1,168 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; +import { PerpsPayRow } from './PerpsPayRow'; +import { useNavigation } from '@react-navigation/native'; +import { useTransactionPayToken } from '../../../../Views/confirmations/hooks/pay/useTransactionPayToken'; +import { useTransactionMetadataRequest } from '../../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest'; +import { useIsPerpsBalanceSelected } from '../../hooks/useIsPerpsBalanceSelected'; +import { useTokenWithBalance } from '../../../../Views/confirmations/hooks/tokens/useTokenWithBalance'; +import { useConfirmationMetricEvents } from '../../../../Views/confirmations/hooks/metrics/useConfirmationMetricEvents'; +import { isHardwareAccount } from '../../../../../util/address'; +import Routes from '../../../../../constants/navigation/Routes'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import { + ConfirmationRowComponentIDs, + TransactionPayComponentIDs, +} from '../../../../Views/confirmations/ConfirmationView.testIds'; + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => key), +})); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: jest.fn(), +})); + +jest.mock('../../../../Views/confirmations/hooks/pay/useTransactionPayToken'); +jest.mock( + '../../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest', +); +jest.mock('../../hooks/useIsPerpsBalanceSelected'); +jest.mock('../../../../Views/confirmations/hooks/tokens/useTokenWithBalance'); +jest.mock( + '../../../../Views/confirmations/hooks/metrics/useConfirmationMetricEvents', +); +jest.mock('../../../../../util/address'); +jest.mock('../../../../Base/TokenIcon', () => jest.fn(() => null)); +jest.mock('../../../../../util/networks', () => ({ + getNetworkImageSource: jest.fn(() => ({ uri: 'network-icon.png' })), +})); + +const mockUseNavigation = useNavigation as jest.MockedFunction< + typeof useNavigation +>; +const mockUseTransactionPayToken = + useTransactionPayToken as jest.MockedFunction; +const mockUseTransactionMetadataRequest = + useTransactionMetadataRequest as jest.MockedFunction< + typeof useTransactionMetadataRequest + >; +const mockUseIsPerpsBalanceSelected = + useIsPerpsBalanceSelected as jest.MockedFunction< + typeof useIsPerpsBalanceSelected + >; +const mockUseTokenWithBalance = useTokenWithBalance as jest.MockedFunction< + typeof useTokenWithBalance +>; +const mockUseConfirmationMetricEvents = + useConfirmationMetricEvents as jest.MockedFunction< + typeof useConfirmationMetricEvents + >; +const mockIsHardwareAccount = isHardwareAccount as jest.MockedFunction< + typeof isHardwareAccount +>; + +describe('PerpsPayRow', () => { + const navigateMock = jest.fn(); + const setConfirmationMetricMock = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockUseNavigation.mockReturnValue({ + navigate: navigateMock, + } as unknown as ReturnType); + mockUseTransactionMetadataRequest.mockReturnValue({ + txParams: { from: '0x123' }, + } as ReturnType); + mockUseTransactionPayToken.mockReturnValue({ + payToken: { + address: '0xusdc', + chainId: '0xa4b1', + symbol: 'USDC', + }, + setPayToken: jest.fn(), + } as unknown as ReturnType); + mockUseIsPerpsBalanceSelected.mockReturnValue(false); + mockUseTokenWithBalance.mockReturnValue({ + address: '0xusdc', + symbol: 'USDC', + image: 'https://example.com/usdc.png', + } as unknown as ReturnType); + mockUseConfirmationMetricEvents.mockReturnValue({ + setConfirmationMetric: setConfirmationMetricMock, + } as unknown as ReturnType); + mockIsHardwareAccount.mockReturnValue(false); + }); + + it('renders pay with label', () => { + const { getByText } = renderWithProvider(); + + expect(getByText('confirm.label.pay_with')).toBeOnTheScreen(); + }); + + it('renders perps balance label when perps balance is selected', () => { + mockUseIsPerpsBalanceSelected.mockReturnValue(true); + + const { getByTestId } = renderWithProvider(); + + expect( + getByTestId(TransactionPayComponentIDs.PAY_WITH_SYMBOL), + ).toHaveTextContent('perps.adjust_margin.perps_balance'); + }); + + it('renders pay token symbol when perps balance is not selected', () => { + mockUseIsPerpsBalanceSelected.mockReturnValue(false); + mockUseTransactionPayToken.mockReturnValue({ + payToken: { address: '0xusdc', chainId: '0xa4b1', symbol: 'USDC' }, + setPayToken: jest.fn(), + } as unknown as ReturnType); + + const { getByTestId } = renderWithProvider(); + + expect( + getByTestId(TransactionPayComponentIDs.PAY_WITH_SYMBOL), + ).toHaveTextContent('USDC'); + }); + + it('navigates to pay with modal when row is pressed and not hardware account', () => { + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(ConfirmationRowComponentIDs.PAY_WITH)); + + expect(navigateMock).toHaveBeenCalledWith( + Routes.CONFIRMATION_PAY_WITH_MODAL, + ); + expect(setConfirmationMetricMock).toHaveBeenCalledWith({ + properties: { mm_pay_token_list_opened: true }, + }); + }); + + it('does not navigate when hardware account', () => { + mockIsHardwareAccount.mockReturnValue(true); + + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(ConfirmationRowComponentIDs.PAY_WITH)); + + expect(navigateMock).not.toHaveBeenCalled(); + expect(setConfirmationMetricMock).not.toHaveBeenCalled(); + }); + + it('calls onPayWithInfoPress when info icon is pressed', () => { + const onPayWithInfoPress = jest.fn(); + const { getByTestId } = renderWithProvider( + , + ); + + fireEvent.press(getByTestId('perps-pay-row-info')); + + expect(onPayWithInfoPress).toHaveBeenCalledTimes(1); + }); + + it('renders with embedded style when embeddedInStack is true', () => { + const { getByTestId } = renderWithProvider(); + + expect(getByTestId(ConfirmationRowComponentIDs.PAY_WITH)).toBeOnTheScreen(); + }); +}); diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.tsx index 31460c03c16..31b55f3149f 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.tsx @@ -1,204 +1,139 @@ -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; import { useNavigation } from '@react-navigation/native'; +import React, { useCallback, useMemo } from 'react'; import { StyleSheet, TouchableOpacity } from 'react-native'; -import { BigNumber } from 'bignumber.js'; import { strings } from '../../../../../../locales/i18n'; -import Routes from '../../../../../constants/navigation/Routes'; -import { Box } from '../../../../UI/Box/Box'; -import { - AlignItems, - FlexDirection, - JustifyContent, -} from '../../../../UI/Box/box.types'; -import Text, { - TextColor, - TextVariant, -} from '../../../../../component-library/components/Texts/Text'; +import Badge, { + BadgeVariant, +} from '../../../../../component-library/components/Badges/Badge'; +import BadgeWrapper, { + BadgePosition, +} from '../../../../../component-library/components/Badges/BadgeWrapper'; import Icon, { IconColor, IconName, IconSize, } from '../../../../../component-library/components/Icons/Icon'; -import { TokenIcon } from '../../../../Views/confirmations/components/token-icon'; -import { useStyles } from '../../../../hooks/useStyles'; -import styleSheet from '../../../../Views/confirmations/components/rows/pay-with-row/pay-with-row.styles'; -import useFiatFormatter from '../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter'; -import { usePerpsLiveAccount } from '../../hooks/stream/usePerpsLiveAccount'; -import { usePerpsNetwork } from '../../hooks/usePerpsNetwork'; -import { - ARBITRUM_MAINNET_CHAIN_ID_HEX, - ARBITRUM_SEPOLIA_CHAIN_ID, - HYPERLIQUID_MAINNET_CHAIN_ID, - HYPERLIQUID_TESTNET_CHAIN_ID, - USDC_ARBITRUM_MAINNET_ADDRESS, - USDC_ARBITRUM_TESTNET_ADDRESS, -} from '../../constants/hyperLiquidConfig'; -import BaseTokenIcon from '../../../../Base/TokenIcon'; -import BadgeWrapper, { - BadgePosition, -} from '../../../../../component-library/components/Badges/BadgeWrapper'; -import Badge, { - BadgeVariant, -} from '../../../../../component-library/components/Badges/Badge'; +import Text, { + TextColor, + TextVariant, +} from '../../../../../component-library/components/Texts/Text'; +import Routes from '../../../../../constants/navigation/Routes'; +import { isHardwareAccount } from '../../../../../util/address'; import { getNetworkImageSource } from '../../../../../util/networks'; -import { useTokenWithBalance } from '../../../../Views/confirmations/hooks/tokens/useTokenWithBalance'; +import { useTheme } from '../../../../../util/theme'; +import BaseTokenIcon from '../../../../Base/TokenIcon'; +import { Box } from '../../../../UI/Box/Box'; +import { AlignItems, FlexDirection } from '../../../../UI/Box/box.types'; import { ConfirmationRowComponentIDs, TransactionPayComponentIDs, } from '../../../../Views/confirmations/ConfirmationView.testIds'; import { useConfirmationMetricEvents } from '../../../../Views/confirmations/hooks/metrics/useConfirmationMetricEvents'; -import { isHardwareAccount } from '../../../../../util/address'; -import { useTransactionMetadataRequest } from '../../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest'; import { useTransactionPayToken } from '../../../../Views/confirmations/hooks/pay/useTransactionPayToken'; -import type { Hex } from '@metamask/utils'; +import { useTokenWithBalance } from '../../../../Views/confirmations/hooks/tokens/useTokenWithBalance'; +import { useTransactionMetadataRequest } from '../../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest'; +import { + PERPS_BALANCE_CHAIN_ID, + PERPS_BALANCE_PLACEHOLDER_ADDRESS, +} from '../../constants/perpsConfig'; +import { PERPS_BALANCE_ICON_URI } from '../../hooks/usePerpsBalanceTokenFilter'; +import { useIsPerpsBalanceSelected } from '../../hooks/useIsPerpsBalanceSelected'; +import { Hex } from '@metamask/utils'; const tokenIconStyles = StyleSheet.create({ - icon: { - width: 32, - height: 32, - borderRadius: 16, + iconSmall: { + width: 24, + height: 24, + borderRadius: 12, }, }); -interface PerpsPayRowProps { - onCustomTokenSelected?: () => void; +const createPayRowStyles = (colors: { background: { section: string } }) => + StyleSheet.create({ + payRowSection: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: colors.background.section, + borderRadius: 8, + padding: 12, + marginBottom: 12, + }, + /** When embedded below another box (e.g. TP/SL), parent provides background and radius */ + payRowEmbedded: { + borderRadius: 0, + marginBottom: 0, + }, + payRowLeft: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + }, + infoIcon: { + marginLeft: 0, + padding: 10, + marginRight: -6, + marginTop: -10, + marginBottom: -10, + }, + }); + +export interface PerpsPayRowProps { + /** Optional callback when the info (i) icon is pressed, e.g. for tooltip */ + onPayWithInfoPress?: () => void; + /** When true, row is stacked below another box (e.g. TP/SL); parent provides background and border radius */ + embeddedInStack?: boolean; } export const PerpsPayRow = ({ - onCustomTokenSelected, -}: PerpsPayRowProps = {}) => { + onPayWithInfoPress, + embeddedInStack = false, +}: PerpsPayRowProps) => { const navigation = useNavigation(); - const formatFiat = useFiatFormatter({ currency: 'usd' }); - const { styles } = useStyles(styleSheet, {}); + const { colors } = useTheme(); + const styles = createPayRowStyles(colors); const { setConfirmationMetric } = useConfirmationMetricEvents(); - const currentNetwork = usePerpsNetwork(); const { payToken } = useTransactionPayToken(); - - // Track if user has explicitly interacted with token selection - const [hasUserInteracted, setHasUserInteracted] = useState(false); - const initialPayTokenRef = useRef(null); - - // Get Perps balance from live account - const { account: perpsAccount } = usePerpsLiveAccount({ throttleMs: 1000 }); - const availableBalance = perpsAccount?.availableBalance || '0'; + const transactionMeta = useTransactionMetadataRequest(); + const matchesPerpsBalance = useIsPerpsBalanceSelected(); const { txParams: { from }, - } = useTransactionMetadataRequest() ?? { txParams: {} }; + } = transactionMeta ?? { txParams: {} }; const canEdit = !isHardwareAccount(from ?? ''); - // Determine HyperLiquid chain ID and USDC address based on network - const hyperliquidChainId = useMemo( - () => - currentNetwork === 'testnet' - ? HYPERLIQUID_TESTNET_CHAIN_ID - : HYPERLIQUID_MAINNET_CHAIN_ID, - [currentNetwork], - ); - - const usdcAddress = useMemo( - () => - currentNetwork === 'testnet' - ? USDC_ARBITRUM_TESTNET_ADDRESS - : USDC_ARBITRUM_MAINNET_ADDRESS, - [currentNetwork], - ); - - // Store initial payToken on mount - useEffect(() => { - if (payToken && initialPayTokenRef.current === null) { - initialPayTokenRef.current = `${payToken.chainId}-${payToken.address}`; - } - }, [payToken]); - - // Detect when payToken changes from initial value (user selected a different token) - useEffect(() => { - if (payToken && initialPayTokenRef.current !== null) { - const currentTokenKey = `${payToken.chainId}-${payToken.address}`; - if (currentTokenKey !== initialPayTokenRef.current) { - setHasUserInteracted(true); - onCustomTokenSelected?.(); - } - } - }, [payToken, onCustomTokenSelected]); - const handleClick = useCallback(() => { if (!canEdit) return; - // Mark that user has interacted when they open the modal - setHasUserInteracted(true); - onCustomTokenSelected?.(); setConfirmationMetric({ properties: { mm_pay_token_list_opened: true, }, }); navigation.navigate(Routes.CONFIRMATION_PAY_WITH_MODAL); - }, [canEdit, navigation, setConfirmationMetric, onCustomTokenSelected]); - - // Determine Arbitrum chain ID for token lookup (USDC is stored under Arbitrum chain ID) - const arbitrumChainId = useMemo( - () => - currentNetwork === 'testnet' - ? ARBITRUM_SEPOLIA_CHAIN_ID - : ARBITRUM_MAINNET_CHAIN_ID_HEX, - [currentNetwork], - ); - - // Determine what to display based on user interaction - const displayToken = useMemo(() => { - // If user hasn't interacted, always show Perps balance - if (!hasUserInteracted) { - return { - address: usdcAddress as Hex, - tokenLookupChainId: arbitrumChainId as Hex, // Use Arbitrum to find token - networkBadgeChainId: hyperliquidChainId as Hex, // Use HyperLiquid for network badge - label: strings('perps.adjust_margin.perps_balance'), - balance: availableBalance, - }; - } - - // Show the selected token - if (!payToken) { - // Fallback to Perps balance if no token (shouldn't happen) - return { - address: usdcAddress as Hex, - tokenLookupChainId: arbitrumChainId as Hex, - networkBadgeChainId: hyperliquidChainId as Hex, - label: strings('perps.adjust_margin.perps_balance'), - balance: availableBalance, + }, [canEdit, navigation, setConfirmationMetric]); + + // Display data: use local state (defaults to Perps balance) so UI always shows "Perps balance" by default + const displayToken = matchesPerpsBalance + ? { + address: PERPS_BALANCE_PLACEHOLDER_ADDRESS, + tokenLookupChainId: PERPS_BALANCE_CHAIN_ID, + networkBadgeChainId: PERPS_BALANCE_CHAIN_ID, + symbol: strings('perps.adjust_margin.perps_balance'), + } + : { + address: payToken?.address ?? PERPS_BALANCE_PLACEHOLDER_ADDRESS, + tokenLookupChainId: payToken?.chainId ?? CHAIN_IDS.MAINNET, + networkBadgeChainId: payToken?.chainId ?? CHAIN_IDS.MAINNET, + symbol: payToken?.symbol ?? '', }; - } - return { - address: payToken.address as Hex, - tokenLookupChainId: payToken.chainId as Hex, - networkBadgeChainId: payToken.chainId as Hex, // Use same chainId for both - label: `${strings('confirm.label.pay_with')} ${payToken.symbol}`, - balance: payToken.balanceUsd ?? '0', - }; - }, [ - hasUserInteracted, - payToken, - usdcAddress, - arbitrumChainId, - hyperliquidChainId, - availableBalance, - ]); - - // Get token for icon (use tokenLookupChainId to find the token) const token = useTokenWithBalance( - displayToken.address, + displayToken.address as unknown as Hex, displayToken.tokenLookupChainId, ); - // Get network badge image source (use networkBadgeChainId for the badge) const networkImageSource = useMemo( () => getNetworkImageSource({ @@ -207,75 +142,84 @@ export const PerpsPayRow = ({ [displayToken.networkBadgeChainId], ); - const balanceUsdFormatted = useMemo( - () => formatFiat(new BigNumber(displayToken.balance)), - [formatFiat, displayToken.balance], - ); - return ( - {token ? ( - - } - > - - - ) : ( - - )} - - {displayToken.label} + + {strings('confirm.label.pay_with')} - onPayWithInfoPress?.()} + style={styles.infoIcon} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + testID="perps-pay-row-info" > - {balanceUsdFormatted} - - {canEdit && from && ( + + + + {matchesPerpsBalance ? ( + <> + + + {strings('perps.adjust_margin.perps_balance')} + + + ) : ( + <> + {token ? ( + + } + > + + + ) : null} + + {displayToken.symbol} + + )} diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsFundingTransactionView.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsFundingTransactionView.tsx index 0f9752229ff..5bd88d1f8b2 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsFundingTransactionView.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsFundingTransactionView.tsx @@ -21,7 +21,7 @@ import Text, { import { useStyles } from '../../../../../component-library/hooks'; import { selectSelectedInternalAccountByScope } from '../../../../../selectors/multichainAccounts/accounts'; import ScreenView from '../../../../Base/ScreenView'; -import HeaderCenter from '../../../../../component-library/components-temp/HeaderCenter'; +import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; import PerpsTransactionDetailAssetHero from '../../components/PerpsTransactionDetailAssetHero'; import { usePerpsBlockExplorerUrl } from '../../hooks'; import { PerpsNavigationParamList } from '../../types/navigation'; @@ -59,7 +59,10 @@ const PerpsFundingTransactionView: React.FC = () => { if (!transaction) { return ( - navigation.goBack()} /> + navigation.goBack()} + /> {strings('perps.transactions.not_found')} @@ -111,7 +114,7 @@ const PerpsFundingTransactionView: React.FC = () => { return ( - navigation.goBack()} diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.tsx index 83a7f513f49..6b8e25035ec 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.tsx @@ -21,7 +21,7 @@ import Button, { import { useStyles } from '../../../../../component-library/hooks'; import { selectSelectedInternalAccountByScope } from '../../../../../selectors/multichainAccounts/accounts'; import ScreenView from '../../../../Base/ScreenView'; -import HeaderCenter from '../../../../../component-library/components-temp/HeaderCenter'; +import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; import PerpsTransactionDetailAssetHero from '../../components/PerpsTransactionDetailAssetHero'; import { usePerpsBlockExplorerUrl, usePerpsOrderFees } from '../../hooks'; import { PerpsNavigationParamList } from '../../types/navigation'; @@ -59,7 +59,10 @@ const PerpsOrderTransactionView: React.FC = () => { if (!transaction) { return ( - navigation.goBack()} /> + navigation.goBack()} + /> {strings('perps.transactions.not_found')} @@ -129,7 +132,7 @@ const PerpsOrderTransactionView: React.FC = () => { return ( - navigation.goBack()} diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx index 7ce3f8e64b2..ed0631383df 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx @@ -22,7 +22,7 @@ import { useStyles } from '../../../../../component-library/hooks'; import { selectSelectedInternalAccountByScope } from '../../../../../selectors/multichainAccounts/accounts'; import Routes from '../../../../../constants/navigation/Routes'; import ScreenView from '../../../../Base/ScreenView'; -import HeaderCenter from '../../../../../component-library/components-temp/HeaderCenter'; +import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; import PerpsTransactionDetailAssetHero from '../../components/PerpsTransactionDetailAssetHero'; import { usePerpsBlockExplorerUrl } from '../../hooks'; import { PerpsNavigationParamList } from '../../types/navigation'; @@ -72,7 +72,10 @@ const PerpsPositionTransactionView: React.FC = () => { // Handle missing transaction data return ( - navigation.goBack()} /> + navigation.goBack()} + /> {strings('perps.transactions.not_found')} @@ -174,7 +177,7 @@ const PerpsPositionTransactionView: React.FC = () => { return ( - navigation.goBack()} includesTopInset diff --git a/app/components/UI/Perps/Views/PerpsWithdrawView/PerpsWithdrawView.tsx b/app/components/UI/Perps/Views/PerpsWithdrawView/PerpsWithdrawView.tsx index 35b1df41d3c..6673706aa3d 100644 --- a/app/components/UI/Perps/Views/PerpsWithdrawView/PerpsWithdrawView.tsx +++ b/app/components/UI/Perps/Views/PerpsWithdrawView/PerpsWithdrawView.tsx @@ -16,7 +16,7 @@ import { BoxFlexDirection, BoxJustifyContent, } from '@metamask/design-system-react-native'; -import HeaderCenter from '../../../../../component-library/components-temp/HeaderCenter'; +import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { PerpsWithdrawViewSelectorsIDs } from '../../Perps.testIds'; import { strings } from '../../../../../../locales/i18n'; @@ -364,7 +364,7 @@ const PerpsWithdrawView: React.FC = () => { {/* Header */} - { getByTestId(PerpsBottomSheetTooltipSelectorsIDs.GOT_IT_BUTTON), ); - await waitFor(() => { - expect(mockOnClose).toHaveBeenCalledTimes(1); - }); - }); + await waitFor( + () => { + expect(mockOnClose).toHaveBeenCalledTimes(1); + }, + { timeout: 10000 }, + ); + }, 15000); it('renders different content for different contentKey (Margin Tooltip)', () => { const { getByText } = renderBottomSheetTooltip({ diff --git a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.tsx b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.tsx index d3726c56f37..29a6bd6a19f 100644 --- a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.tsx +++ b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.tsx @@ -4,7 +4,7 @@ import { View } from 'react-native'; import BottomSheet, { BottomSheetRef, } from '../../../../../component-library/components/BottomSheets/BottomSheet'; -import HeaderCenter from '../../../../../component-library/components-temp/HeaderCenter'; +import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; import BottomSheetFooter, { ButtonsAlignment, } from '../../../../../component-library/components/BottomSheets/BottomSheetFooter'; @@ -147,7 +147,7 @@ const PerpsBottomSheetTooltip = React.memo( testID={testID} > {!hasCustomHeader && ( - { {providerFee} + + {/* Bridge Fee Row (when paying with custom token) */} + {data?.bridgeFeeFormatted ? ( + + + {strings('perps.tooltips.fees.bridge_fee')} + + + {data.bridgeFeeFormatted} + + + ) : null} ); }; diff --git a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/contentRegistry.test.ts b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/contentRegistry.test.ts index 8ff6c598866..757a90fcf83 100644 --- a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/contentRegistry.test.ts +++ b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/contentRegistry.test.ts @@ -46,6 +46,7 @@ describe('tooltipContentRegistry', () => { 'tp_sl', 'close_position_you_receive', 'points', + 'pay_with', ]; undefinedKeys.forEach((key) => { @@ -76,6 +77,7 @@ describe('tooltipContentRegistry', () => { 'close_position_you_receive', 'tpsl_count_warning', 'points', + 'pay_with', ]; expectedKeys.forEach((key) => { diff --git a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/contentRegistry.ts b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/contentRegistry.ts index 2b8abf1d97a..fc4f9d9c057 100644 --- a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/contentRegistry.ts +++ b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/contentRegistry.ts @@ -37,4 +37,5 @@ export const tooltipContentRegistry: ContentRegistry = { after_hours_trading: MarketHoursContent, oracle_price: undefined, spread: undefined, + pay_with: undefined, }; diff --git a/app/components/UI/Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.test.tsx b/app/components/UI/Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.test.tsx index 579bb805088..4dc06c07e63 100644 --- a/app/components/UI/Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.test.tsx +++ b/app/components/UI/Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.test.tsx @@ -94,7 +94,7 @@ jest.mock('../../../../../component-library/hooks', () => ({ })); jest.mock( - '../../../../../component-library/components-temp/HeaderCenter', + '../../../../../component-library/components-temp/HeaderCompactStandard', () => { const { View, Text, TouchableOpacity } = jest.requireActual('react-native'); return { diff --git a/app/components/UI/Perps/constants/perpsConfig.ts b/app/components/UI/Perps/constants/perpsConfig.ts index bde6a6931b5..6edc50e026f 100644 --- a/app/components/UI/Perps/constants/perpsConfig.ts +++ b/app/components/UI/Perps/constants/perpsConfig.ts @@ -1,11 +1,23 @@ +import type { Hex } from '@metamask/utils'; import { TokenI } from '../../Tokens/types'; +/** Address used to represent "Perps balance" as the payment token (synthetic option). */ +export const PERPS_BALANCE_PLACEHOLDER_ADDRESS = + '0x0000000000000000000000000000000000000000' as Hex; + +/** Chain id used for the "Perps balance" payment option. */ +export { ARBITRUM_CHAIN_ID as PERPS_BALANCE_CHAIN_ID } from '@metamask/swaps-controller/dist/constants'; + /** * Perps feature constants */ export const PERPS_CONSTANTS = { FeatureFlagKey: 'perpsEnabled', FeatureName: 'perps', // Constant for Sentry error filtering - enables "feature:perps" dashboard queries + /** Token description used to identify the synthetic "Perps balance" option in pay-with token lists */ + PerpsBalanceTokenDescription: 'perps-balance', + /** Symbol displayed for the synthetic "Perps balance" token in pay-with token lists */ + PerpsBalanceTokenSymbol: 'USD', WebsocketTimeout: 5000, // 5 seconds WebsocketCleanupDelay: 1000, // 1 second BackgroundDisconnectDelay: 20_000, // 20 seconds delay before disconnecting when app is backgrounded or when user exits perps UX @@ -25,6 +37,9 @@ export const PERPS_CONSTANTS = { BalanceUpdateThrottleMs: 15000, // Update at most every 15 seconds to reduce state updates in PerpsConnectionManager InitialDataDelayMs: 100, // Delay to allow initial data to load after connection establishment + // Deposit toast timing + DepositTakingLongerToastDelayMs: 15_000, // Delay before showing "Deposit taking longer than usual" toast + DefaultAssetPreviewLimit: 5, DefaultMaxLeverage: 3 as number, // Default fallback max leverage when market data is unavailable - conservative default FallbackPriceDisplay: '$---', // Display when price data is unavailable diff --git a/app/components/UI/Perps/contexts/PerpsOrderContext.test.tsx b/app/components/UI/Perps/contexts/PerpsOrderContext.test.tsx index d8601bc37b4..77431e8caf6 100644 --- a/app/components/UI/Perps/contexts/PerpsOrderContext.test.tsx +++ b/app/components/UI/Perps/contexts/PerpsOrderContext.test.tsx @@ -43,6 +43,7 @@ describe('PerpsOrderContext', () => { handleMaxAmount: jest.fn(), handleMinAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, }; beforeEach(() => { @@ -92,6 +93,7 @@ describe('PerpsOrderContext', () => { initialAmount: undefined, initialLeverage: undefined, initialType: undefined, + effectiveAvailableBalance: undefined, }); }); @@ -110,7 +112,10 @@ describe('PerpsOrderContext', () => { , ); - expect(mockUsePerpsOrderForm).toHaveBeenCalledWith(initialProps); + expect(mockUsePerpsOrderForm).toHaveBeenCalledWith({ + ...initialProps, + effectiveAvailableBalance: undefined, + }); }); it('provides the order form state to context', () => { diff --git a/app/components/UI/Perps/contexts/PerpsOrderContext.tsx b/app/components/UI/Perps/contexts/PerpsOrderContext.tsx index 15673e8f677..da9e05dc5e1 100644 --- a/app/components/UI/Perps/contexts/PerpsOrderContext.tsx +++ b/app/components/UI/Perps/contexts/PerpsOrderContext.tsx @@ -19,6 +19,8 @@ interface PerpsOrderProviderProps { initialLeverage?: number; initialType?: OrderType; existingPosition?: Position; + /** When paying with a custom token, the selected token amount in USD; caps maxPossibleAmount and amount handlers */ + effectiveAvailableBalance?: number; } export const PerpsOrderProvider = ({ @@ -29,6 +31,7 @@ export const PerpsOrderProvider = ({ initialLeverage, initialType, existingPosition, + effectiveAvailableBalance, }: PerpsOrderProviderProps) => { const orderFormState = usePerpsOrderForm({ initialAsset, @@ -36,6 +39,7 @@ export const PerpsOrderProvider = ({ initialAmount, initialLeverage: initialLeverage ?? existingPosition?.leverage?.value, initialType, + effectiveAvailableBalance, }); return ( diff --git a/app/components/UI/Perps/controllers/PerpsController.test.ts b/app/components/UI/Perps/controllers/PerpsController.test.ts index c94e9919e13..88fff778145 100644 --- a/app/components/UI/Perps/controllers/PerpsController.test.ts +++ b/app/components/UI/Perps/controllers/PerpsController.test.ts @@ -17,9 +17,11 @@ import { GasFeeEstimateType, } from '@metamask/transaction-controller'; import type { + AccountState, PerpsProvider, PerpsPlatformDependencies, PerpsProviderType, + SubscribeAccountParams, } from './types'; import { HyperLiquidProvider } from './providers/HyperLiquidProvider'; import { createMockHyperLiquidProvider } from '../__mocks__/providerMocks'; @@ -1871,7 +1873,50 @@ describe('PerpsController', () => { const unsubscribe = controller.subscribeToAccount(params); expect(unsubscribe).toBe(mockUnsubscribe); - expect(mockProvider.subscribeToAccount).toHaveBeenCalledWith(params); + // Controller wraps callback to update state, so expect a function rather than exact params + expect(mockProvider.subscribeToAccount).toHaveBeenCalledWith( + expect.objectContaining({ callback: expect.any(Function) }), + ); + }); + + it('updates accountState when subscribeToAccount callback receives non-null account', () => { + const originalCallback = jest.fn(); + let wrappedCallback: (account: AccountState | null) => void = () => { + /* assigned by mock */ + }; + mockProvider.subscribeToAccount.mockImplementation( + (p: SubscribeAccountParams) => { + wrappedCallback = p.callback; + return jest.fn(); + }, + ); + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + controller.subscribeToAccount({ callback: originalCallback }); + + const accountState = { + availableBalance: '5000', + totalBalance: '5000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + wrappedCallback(accountState); + + expect(controller.state.accountState).toMatchObject(accountState); + expect(originalCallback).toHaveBeenCalledWith(accountState); + }); + + it('returns no-op unsub and does not throw when subscribeToAccount called before init', () => { + const params = { callback: jest.fn() }; + + const unsubscribe = controller.subscribeToAccount(params); + + expect(typeof unsubscribe).toBe('function'); + expect(() => unsubscribe()).not.toThrow(); + expect(mockProvider.subscribeToAccount).not.toHaveBeenCalled(); }); }); @@ -3700,4 +3745,64 @@ describe('PerpsController', () => { expect(savedGrouping).toBe(100); }); }); + + describe('setSelectedPaymentToken', () => { + it('sets selectedPaymentToken to null when passed null', () => { + controller.testUpdate((state) => { + state.selectedPaymentToken = { + description: 'USDC', + address: '0xa0b8', + chainId: '0x1', + } as PerpsControllerState['selectedPaymentToken']; + }); + + controller.setSelectedPaymentToken(null); + + expect(controller.state.selectedPaymentToken).toBeNull(); + }); + + it('sets selectedPaymentToken to null when token has PerpsBalanceTokenDescription', () => { + controller.setSelectedPaymentToken({ + description: 'perps-balance', + address: '0x0', + chainId: '0x1', + } as Parameters[0]); + + expect(controller.state.selectedPaymentToken).toBeNull(); + }); + + it('stores description, address and chainId when passed a normal token', () => { + const token = { + description: 'USDC', + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as const, + chainId: '0x1' as const, + }; + + controller.setSelectedPaymentToken( + token as Parameters[0], + ); + + expect(controller.state.selectedPaymentToken).toMatchObject({ + description: 'USDC', + address: token.address, + chainId: token.chainId, + }); + }); + }); + + describe('resetSelectedPaymentToken', () => { + it('sets selectedPaymentToken to null', () => { + controller.testUpdate((state) => { + state.selectedPaymentToken = { + description: 'USDC', + address: '0xa0b8', + chainId: '0x1', + } as PerpsControllerState['selectedPaymentToken']; + }); + + controller.resetSelectedPaymentToken(); + + expect(controller.state.selectedPaymentToken).toBeNull(); + }); + }); }); diff --git a/app/components/UI/Perps/controllers/PerpsController.ts b/app/components/UI/Perps/controllers/PerpsController.ts index 495ac18d2d8..2aad3dbe21e 100644 --- a/app/components/UI/Perps/controllers/PerpsController.ts +++ b/app/components/UI/Perps/controllers/PerpsController.ts @@ -120,9 +120,21 @@ import type { RemoteFeatureFlagControllerStateChangeEvent, RemoteFeatureFlagControllerGetStateAction, } from '@metamask/remote-feature-flag-controller'; +import type { Json } from '@metamask/utils'; import { wait } from '../utils/wait'; import { getSelectedEvmAccount } from '../utils/accountUtils'; import { ORIGIN_METAMASK } from '@metamask/controller-utils'; +import type { AssetType } from '../../../Views/confirmations/types/token'; + +/** + * Minimal payment token stored in PerpsController state. + * Only required fields for identification and Perps balance detection. + */ +export interface SelectedPaymentTokenSnapshot { + description?: string; + address: string; + chainId: string; +} // Re-export error codes from separate file to avoid circular dependencies export { PERPS_ERROR_CODES, type PerpsErrorCode } from './perpsErrorCodes'; @@ -286,6 +298,9 @@ export type PerpsControllerState = { // HIP-3 Configuration Version (incremented when HIP-3 remote flags change) // Used to trigger reconnection and cache invalidation in ConnectionManager hip3ConfigVersion: number; + + // Selected payment token for Perps order/deposit flow (null = Perps balance). Stored as Json (minimal shape: description, address, chainId). + selectedPaymentToken: Json | null; }; /** @@ -340,6 +355,7 @@ export const getDefaultPerpsControllerState = (): PerpsControllerState => ({ direction: MARKET_SORTING_CONFIG.DefaultDirection, }, hip3ConfigVersion: 0, + selectedPaymentToken: null, }); /** @@ -490,6 +506,12 @@ const metadata: StateMetadata = { includeInDebugSnapshot: false, usedInUi: false, }, + selectedPaymentToken: { + includeInStateLogs: false, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, }; /** @@ -632,6 +654,14 @@ export type PerpsControllerActions = | { type: 'PerpsController:saveOrderBookGrouping'; handler: PerpsController['saveOrderBookGrouping']; + } + | { + type: 'PerpsController:setSelectedPaymentToken'; + handler: PerpsController['setSelectedPaymentToken']; + } + | { + type: 'PerpsController:resetSelectedPaymentToken'; + handler: PerpsController['resetSelectedPaymentToken']; }; /** @@ -1279,6 +1309,14 @@ export class PerpsController extends BaseController< }; } + /** + * Returns current controller state as PerpsControllerState. + * Used by createServiceContext to avoid deep type instantiation when building stateManager. + */ + private getControllerState(): PerpsControllerState { + return this.state as unknown as PerpsControllerState; + } + /** * Create a ServiceContext for dependency injection into services * Provides all orchestration dependencies (tracing, analytics, state management) @@ -1301,11 +1339,13 @@ export class PerpsController extends BaseController< method, }, stateManager: { - update: (updater) => this.update(updater), - getState: () => this.state, + update: (updater: (state: PerpsControllerState) => void) => + // @ts-expect-error TS2589 - excessively deep instantiation when inferring stateManager from BaseController + this.update(updater), + getState: (): PerpsControllerState => this.getControllerState(), }, ...additionalContext, - }; + } as ServiceContext; } /** @@ -2406,12 +2446,27 @@ export class PerpsController extends BaseController< } /** - * Subscribe to live account updates + * Subscribe to live account updates. + * Updates controller state (Redux) when new account data arrives so consumers + * like usePerpsBalanceTokenFilter (PayWithModal) see the latest balance. */ subscribeToAccount(params: SubscribeAccountParams): () => void { try { const provider = this.getActiveProvider(); - return provider.subscribeToAccount(params); + const originalCallback = params.callback; + return provider.subscribeToAccount({ + ...params, + callback: (account: AccountState | null) => { + if (account) { + this.update((state) => { + state.accountState = account; + state.lastUpdateTimestamp = Date.now(); + state.lastError = null; + }); + } + originalCallback(account); + }, + }); } catch (error) { this.logError( ensureError(error), @@ -2934,6 +2989,44 @@ export class PerpsController extends BaseController< }); } + /** + * Set the selected payment token for the Perps order/deposit flow. + * Pass null or a token with description PERPS_CONSTANTS.PerpsBalanceTokenDescription to select Perps balance. + * Only required fields (description, address, chainId) are stored in state. + */ + setSelectedPaymentToken(token: AssetType | null): void { + let normalized: AssetType | null = null; + if ( + token != null && + token.description !== PERPS_CONSTANTS.PerpsBalanceTokenDescription + ) { + normalized = token; + } + + let snapshot: Json | null = null; + if (normalized !== null) { + snapshot = { + description: normalized.description, + address: normalized.address, + chainId: normalized.chainId, + } as unknown as Json; + } + + this.update((state) => { + state.selectedPaymentToken = snapshot; + }); + } + + /** + * Reset the selected payment token to Perps balance (null). + * Call when leaving the Perps order view so the next visit defaults to Perps balance. + */ + resetSelectedPaymentToken(): void { + this.update((state) => { + state.selectedPaymentToken = null; + }); + } + /** * Get saved order book grouping for a market * @param symbol - Market symbol diff --git a/app/components/UI/Perps/controllers/aggregation/SubscriptionMultiplexer.test.ts b/app/components/UI/Perps/controllers/aggregation/SubscriptionMultiplexer.test.ts index f8e68e30cbf..294bf20d2fc 100644 --- a/app/components/UI/Perps/controllers/aggregation/SubscriptionMultiplexer.test.ts +++ b/app/components/UI/Perps/controllers/aggregation/SubscriptionMultiplexer.test.ts @@ -20,7 +20,7 @@ interface MockProviderWithEmit extends jest.Mocked> { _emitPositions: (positions: Position[]) => void; _emitOrders: (orders: Order[]) => void; _emitFills: (fills: OrderFill[], isSnapshot?: boolean) => void; - _emitAccount: (account: AccountState) => void; + _emitAccount: (account: AccountState | null) => void; } // Mock provider factory @@ -30,7 +30,7 @@ const createMockProvider = (providerId: string): MockProviderWithEmit => { const orderCallbacks: ((orders: Order[]) => void)[] = []; const fillCallbacks: ((fills: OrderFill[], isSnapshot?: boolean) => void)[] = []; - const accountCallbacks: ((account: AccountState) => void)[] = []; + const accountCallbacks: ((account: AccountState | null) => void)[] = []; return { protocolId: providerId, @@ -82,7 +82,7 @@ const createMockProvider = (providerId: string): MockProviderWithEmit => { _emitFills: (fills: OrderFill[], isSnapshot?: boolean) => { fillCallbacks.forEach((cb) => cb(fills, isSnapshot)); }, - _emitAccount: (account: AccountState) => { + _emitAccount: (account: AccountState | null) => { accountCallbacks.forEach((cb) => cb(account)); }, } as MockProviderWithEmit; @@ -479,6 +479,28 @@ describe('SubscriptionMultiplexer', () => { }), ); }); + + it('removes provider from cache and invokes callback when provider emits null', () => { + const callback = jest.fn(); + + mux.subscribeToAccount({ + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ], + callback, + }); + + mockHLProvider._emitAccount(createMockAccount('10000')); + expect(callback).toHaveBeenLastCalledWith( + expect.arrayContaining([ + expect.objectContaining({ providerId: 'hyperliquid' }), + ]), + ); + + mockHLProvider._emitAccount(null); + + expect(callback).toHaveBeenLastCalledWith([]); + }); }); describe('cache operations', () => { diff --git a/app/components/UI/Perps/controllers/aggregation/SubscriptionMultiplexer.ts b/app/components/UI/Perps/controllers/aggregation/SubscriptionMultiplexer.ts index b50d03f0eb7..ae96a2afc17 100644 --- a/app/components/UI/Perps/controllers/aggregation/SubscriptionMultiplexer.ts +++ b/app/components/UI/Perps/controllers/aggregation/SubscriptionMultiplexer.ts @@ -426,9 +426,16 @@ export class SubscriptionMultiplexer { try { const subscribeParams: SubscribeAccountParams = { callback: (account) => { - // Tag account with providerId and cache - const taggedAccount: AccountState = { ...account, providerId }; - this.accountCache.set(providerId, taggedAccount); + if (account === null) { + this.accountCache.delete(providerId); + } else { + // Tag account with providerId and cache + const taggedAccount: AccountState = { + ...account, + providerId, + }; + this.accountCache.set(providerId, taggedAccount); + } // Emit all cached account states const allAccounts = Array.from(this.accountCache.values()); diff --git a/app/components/UI/Perps/controllers/types/index.ts b/app/components/UI/Perps/controllers/types/index.ts index 72b934c6878..f48b820d46b 100644 --- a/app/components/UI/Perps/controllers/types/index.ts +++ b/app/components/UI/Perps/controllers/types/index.ts @@ -708,7 +708,7 @@ export interface SubscribeOrdersParams { } export interface SubscribeAccountParams { - callback: (account: AccountState) => void; + callback: (account: AccountState | null) => void; accountId?: CaipAccountId; // Optional: defaults to selected account } diff --git a/app/components/UI/Perps/hooks/index.ts b/app/components/UI/Perps/hooks/index.ts index 0b2edb47f32..b578c595419 100644 --- a/app/components/UI/Perps/hooks/index.ts +++ b/app/components/UI/Perps/hooks/index.ts @@ -43,6 +43,12 @@ export { useWithdrawValidation } from './useWithdrawValidation'; // Payment tokens hook export { usePerpsPaymentTokens } from './usePerpsPaymentTokens'; +export { usePerpsPaymentToken } from './usePerpsPaymentToken'; +export { + PERPS_BALANCE_CHAIN_ID, + PERPS_BALANCE_PLACEHOLDER_ADDRESS, +} from '../constants/perpsConfig'; +export { useIsPerpsBalanceSelected } from './useIsPerpsBalanceSelected'; // Margin adjustment hook export { usePerpsAdjustMarginData } from './usePerpsAdjustMarginData'; diff --git a/app/components/UI/Perps/hooks/useIsPerpsBalanceSelected.test.ts b/app/components/UI/Perps/hooks/useIsPerpsBalanceSelected.test.ts new file mode 100644 index 00000000000..d8ef34b29ea --- /dev/null +++ b/app/components/UI/Perps/hooks/useIsPerpsBalanceSelected.test.ts @@ -0,0 +1,33 @@ +import { renderHook } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import { useIsPerpsBalanceSelected } from './useIsPerpsBalanceSelected'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +const mockUseSelector = useSelector as jest.MockedFunction; + +describe('useIsPerpsBalanceSelected', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns true when selector returns true', () => { + mockUseSelector.mockReturnValue(true); + + const { result } = renderHook(() => useIsPerpsBalanceSelected()); + + expect(result.current).toBe(true); + expect(mockUseSelector).toHaveBeenCalledTimes(1); + }); + + it('returns false when selector returns false', () => { + mockUseSelector.mockReturnValue(false); + + const { result } = renderHook(() => useIsPerpsBalanceSelected()); + + expect(result.current).toBe(false); + expect(mockUseSelector).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/components/UI/Perps/hooks/useIsPerpsBalanceSelected.ts b/app/components/UI/Perps/hooks/useIsPerpsBalanceSelected.ts new file mode 100644 index 00000000000..e956b72a294 --- /dev/null +++ b/app/components/UI/Perps/hooks/useIsPerpsBalanceSelected.ts @@ -0,0 +1,10 @@ +import { useSelector } from 'react-redux'; +import { selectIsPerpsBalanceSelected } from '../selectors/perpsController'; + +/** + * Returns whether the user selected the synthetic "Perps balance" option. + * Reads from PerpsController Redux state: selectedPaymentToken === null means Perps balance selected. + */ +export function useIsPerpsBalanceSelected(): boolean { + return useSelector(selectIsPerpsBalanceSelected); +} diff --git a/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts new file mode 100644 index 00000000000..4b456e2a748 --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts @@ -0,0 +1,206 @@ +import { renderHook } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import { TransactionType } from '@metamask/transaction-controller'; +import { usePerpsBalanceTokenFilter } from './usePerpsBalanceTokenFilter'; +import { useTransactionMetadataRequest } from '../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest'; +import { useIsPerpsBalanceSelected } from './useIsPerpsBalanceSelected'; +import { + PERPS_BALANCE_PLACEHOLDER_ADDRESS, + PERPS_CONSTANTS, +} from '../constants/perpsConfig'; +import type { AssetType } from '../../../Views/confirmations/types/token'; + +jest.mock('../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => key), +})); + +jest.mock( + '../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest', +); +jest.mock('./useIsPerpsBalanceSelected'); +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('images/perps-pay-token-icon.png', () => ({ + uri: 'perps-pay-token-icon-uri', +})); + +jest.mock('../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter', () => + jest.fn( + () => (value: { toNumber: () => number }) => + `$${value.toNumber().toFixed(2)}`, + ), +); + +const mockUseTransactionMetadataRequest = + useTransactionMetadataRequest as jest.MockedFunction< + typeof useTransactionMetadataRequest + >; +const mockUseIsPerpsBalanceSelected = + useIsPerpsBalanceSelected as jest.MockedFunction< + typeof useIsPerpsBalanceSelected + >; +const mockUseSelector = useSelector as jest.MockedFunction; + +describe('usePerpsBalanceTokenFilter', () => { + const chainId = '0xa4b1'; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseTransactionMetadataRequest.mockReturnValue(undefined); + mockUseIsPerpsBalanceSelected.mockReturnValue(false); + mockUseSelector.mockImplementation((selector) => { + if (selector.name === 'selectPerpsAccountState') { + return { availableBalance: '1500.00' }; + } + return undefined; + }); + }); + + describe('when transaction is not perpsDepositAndOrder', () => { + it('returns tokens unchanged', () => { + mockUseTransactionMetadataRequest.mockReturnValue({ + type: TransactionType.simpleSend, + } as ReturnType); + const inputTokens: AssetType[] = [ + { + address: '0xabc', + chainId, + symbol: 'USDC', + name: 'USD Coin', + balance: '100', + } as AssetType, + ]; + + const { result } = renderHook(() => usePerpsBalanceTokenFilter()); + const filter = result.current; + const output = filter(inputTokens); + + expect(output).toBe(inputTokens); + expect(output).toHaveLength(1); + expect(output[0].address).toBe('0xabc'); + }); + + it('returns tokens unchanged when transaction meta is undefined', () => { + mockUseTransactionMetadataRequest.mockReturnValue(undefined); + const inputTokens: AssetType[] = [ + { address: '0xdef', chainId, symbol: 'ETH' } as AssetType, + ]; + + const { result } = renderHook(() => usePerpsBalanceTokenFilter()); + const output = result.current(inputTokens); + + expect(output).toEqual(inputTokens); + }); + }); + + describe('when transaction is perpsDepositAndOrder', () => { + beforeEach(() => { + mockUseTransactionMetadataRequest.mockReturnValue({ + type: TransactionType.perpsDepositAndOrder, + } as ReturnType); + }); + + it('prepends perps balance token with correct shape', () => { + mockUseSelector.mockReturnValue({ + availableBalance: '2000.50', + }); + mockUseIsPerpsBalanceSelected.mockReturnValue(true); + const inputTokens: AssetType[] = [ + { + address: '0xusdc', + chainId, + symbol: 'USDC', + name: 'USD Coin', + balance: '500', + isSelected: false, + } as AssetType, + ]; + + const { result } = renderHook(() => usePerpsBalanceTokenFilter()); + const output = result.current(inputTokens); + + expect(output).toHaveLength(2); + const perpsToken = output[0]; + expect(perpsToken.address).toBe(PERPS_BALANCE_PLACEHOLDER_ADDRESS); + expect(perpsToken.tokenId).toBe(PERPS_BALANCE_PLACEHOLDER_ADDRESS); + expect(perpsToken.name).toBe('perps.adjust_margin.perps_balance'); + expect(perpsToken.symbol).toBe('USD'); + expect(perpsToken.balance).toBe('2000.50'); + expect(perpsToken.balanceInSelectedCurrency).toBe('$2000.50'); + expect(perpsToken.decimals).toBe(2); + expect(perpsToken.isETH).toBe(false); + expect(perpsToken.isNative).toBe(false); + expect(perpsToken.isSelected).toBe(true); + expect(perpsToken.description).toBe( + PERPS_CONSTANTS.PerpsBalanceTokenDescription, + ); + }); + + it('uses availableBalance from perps account', () => { + mockUseSelector.mockReturnValue({ + availableBalance: '999.99', + }); + const inputTokens: AssetType[] = []; + + const { result } = renderHook(() => usePerpsBalanceTokenFilter()); + const output = result.current(inputTokens); + + expect(output[0].balance).toBe('999.99'); + expect(output[0].balanceInSelectedCurrency).toBe('$999.99'); + }); + + it('uses zero balance when perps account is null', () => { + mockUseSelector.mockReturnValue(null); + const inputTokens: AssetType[] = []; + + const { result } = renderHook(() => usePerpsBalanceTokenFilter()); + const output = result.current(inputTokens); + + expect(output[0].balance).toBe('0'); + expect(output[0].balanceInSelectedCurrency).toBe('$0.00'); + }); + + it('clears isSelected on other tokens when perps balance is selected', () => { + mockUseIsPerpsBalanceSelected.mockReturnValue(true); + const inputTokens: AssetType[] = [ + { + address: '0xa', + chainId, + symbol: 'USDC', + isSelected: true, + } as AssetType, + { + address: '0xb', + chainId, + symbol: 'DAI', + isSelected: false, + } as AssetType, + ]; + + const { result } = renderHook(() => usePerpsBalanceTokenFilter()); + const output = result.current(inputTokens); + + expect(output[1].isSelected).toBe(false); + expect(output[2].isSelected).toBe(false); + }); + + it('keeps token isSelected when perps balance is not selected', () => { + mockUseIsPerpsBalanceSelected.mockReturnValue(false); + const inputTokens: AssetType[] = [ + { + address: '0xa', + chainId, + symbol: 'USDC', + isSelected: true, + } as AssetType, + ]; + + const { result } = renderHook(() => usePerpsBalanceTokenFilter()); + const output = result.current(inputTokens); + + expect(output[1].isSelected).toBe(true); + }); + }); +}); diff --git a/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts new file mode 100644 index 00000000000..ab2ac4e1d67 --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts @@ -0,0 +1,93 @@ +import { TransactionType } from '@metamask/transaction-controller'; +import { BigNumber } from 'bignumber.js'; +import { useCallback } from 'react'; +import { Image } from 'react-native'; +import { useSelector } from 'react-redux'; +import { strings } from '../../../../../locales/i18n'; +import useFiatFormatter from '../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter'; +import { useTransactionMetadataRequest } from '../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest'; +import { AssetType } from '../../../Views/confirmations/types/token'; +import { hasTransactionType } from '../../../Views/confirmations/utils/transaction'; +import perpsPayTokenIcon from 'images/perps-pay-token-icon.png'; +import { + PERPS_BALANCE_CHAIN_ID, + PERPS_BALANCE_PLACEHOLDER_ADDRESS, + PERPS_CONSTANTS, +} from '../constants/perpsConfig'; +import { selectPerpsAccountState } from '../selectors/perpsController'; +import { useIsPerpsBalanceSelected } from './useIsPerpsBalanceSelected'; + +/** URI for the perps balance token icon, shared with PerpsPayRow and pay-with modal. */ +const resolvedPerpsIcon = Image.resolveAssetSource(perpsPayTokenIcon); +export const PERPS_BALANCE_ICON_URI = resolvedPerpsIcon?.uri ?? ''; + +/** + * Returns a filter that prepends a synthetic "Perps balance" token to the list + * when the transaction type is perpsDepositAndOrder. The token shows the perps + * account balance, USDC icon, and label "Perps balance". + * + * Uses PerpsController state (Redux) so it works in any screen, including + * PayWithModal and confirmations where PerpsStreamProvider is not mounted. + */ +export function usePerpsBalanceTokenFilter(): ( + tokens: AssetType[], +) => AssetType[] { + const transactionMeta = useTransactionMetadataRequest(); + const isPerpsBalanceSelected = useIsPerpsBalanceSelected(); + const perpsAccount = useSelector(selectPerpsAccountState); + const formatFiat = useFiatFormatter({ currency: 'usd' }); + + const filterAllowedTokens = useCallback( + (tokens: AssetType[]): AssetType[] => { + if ( + !hasTransactionType(transactionMeta, [ + TransactionType.perpsDepositAndOrder, + ]) + ) { + return tokens; + } + + const chainId = PERPS_BALANCE_CHAIN_ID; + + const availableBalance = perpsAccount?.availableBalance || '0'; + const balanceInSelectedCurrency = formatFiat( + new BigNumber(availableBalance), + ); + + const perpsBalanceName = strings('perps.adjust_margin.perps_balance'); + + const perpsBalanceToken: AssetType = { + address: PERPS_BALANCE_PLACEHOLDER_ADDRESS, + chainId, + tokenId: PERPS_BALANCE_PLACEHOLDER_ADDRESS, + name: perpsBalanceName, + symbol: PERPS_CONSTANTS.PerpsBalanceTokenSymbol, + balance: availableBalance, + balanceInSelectedCurrency, + image: PERPS_BALANCE_ICON_URI, + logo: PERPS_BALANCE_ICON_URI, + decimals: 2, + isETH: false, + isNative: false, + isSelected: isPerpsBalanceSelected, + description: PERPS_CONSTANTS.PerpsBalanceTokenDescription, + }; + + const mappedTokens = tokens.map((token) => ({ + ...token, + isSelected: + token.isSelected && isPerpsBalanceSelected ? false : token.isSelected, + })); + + return [perpsBalanceToken, ...mappedTokens]; + }, + [ + transactionMeta, + isPerpsBalanceSelected, + perpsAccount?.availableBalance, + formatFiat, + ], + ); + + return filterAllowedTokens; +} diff --git a/app/components/UI/Perps/hooks/usePerpsDepositStatus.test.ts b/app/components/UI/Perps/hooks/usePerpsDepositStatus.test.ts index d13c4c34ce8..7ff1dc82669 100644 --- a/app/components/UI/Perps/hooks/usePerpsDepositStatus.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsDepositStatus.test.ts @@ -47,13 +47,8 @@ jest.mock('../../../../core/redux/slices/confirmationMetrics', () => ({ selectTransactionBridgeQuotesById: jest.fn(), })); -// Mock stream manager -const mockStreamManager = { - hasActiveDepositHandler: jest.fn(), -}; - jest.mock('../providers/PerpsStreamManager', () => ({ - getStreamManagerInstance: jest.fn(() => mockStreamManager), + getStreamManagerInstance: jest.fn(() => ({})), })); const mockUseSelector = useSelector as jest.MockedFunction; @@ -87,7 +82,6 @@ describe('usePerpsDepositStatus', () => { mockUnsubscribe = jest.fn(); mockShowToast = jest.fn(); mockClearDepositResult = jest.fn(); - mockStreamManager.hasActiveDepositHandler.mockReturnValue(false); mockEngine.controllerMessenger.subscribe = mockSubscribe; mockEngine.controllerMessenger.unsubscribe = mockUnsubscribe; @@ -148,6 +142,26 @@ describe('usePerpsDepositStatus', () => { ], hapticsType: NotificationFeedbackType.Success, })), + takingLonger: { + variant: ToastVariants.Icon, + iconName: IconName.Warning, + hasNoTimeout: true, + labelOptions: [ + { label: 'Deposit taking longer', isBold: true }, + { label: 'Your deposit is still processing' }, + ], + hapticsType: NotificationFeedbackType.Warning, + } as PerpsToastOptions, + tradeCanceled: { + variant: ToastVariants.Icon, + iconName: IconName.Warning, + hasNoTimeout: false, + labelOptions: [ + { label: 'Trade canceled', isBold: true }, + { label: 'Funds returned to account' }, + ], + hapticsType: NotificationFeedbackType.Warning, + } as PerpsToastOptions, }, oneClickTrade: { txCreationFailed: {} as PerpsToastOptions, @@ -424,25 +438,6 @@ describe('usePerpsDepositStatus', () => { mockPerpsToastOptions.accountManagement.deposit.inProgress, ).toHaveBeenCalledWith(60, 'test-tx-id'); // 60 seconds for other tokens }); - - it('skips showing toast when active deposit handler exists', () => { - mockStreamManager.hasActiveDepositHandler.mockReturnValue(true); - mockShowToast.mockClear(); - - renderHook(() => usePerpsDepositStatus()); - const transactionMeta: TransactionMeta = { - id: 'test-tx-id', - type: TransactionType.perpsDeposit, - status: TransactionStatus.approved, - } as TransactionMeta; - - act(() => { - transactionHandler({ transactionMeta }); - }); - - expect(mockShowToast).not.toHaveBeenCalled(); - expect(mockStreamManager.hasActiveDepositHandler).toHaveBeenCalled(); - }); }); describe('Balance Monitoring', () => { diff --git a/app/components/UI/Perps/hooks/usePerpsDepositStatus.ts b/app/components/UI/Perps/hooks/usePerpsDepositStatus.ts index 917c1466fb7..2d4894093ba 100644 --- a/app/components/UI/Perps/hooks/usePerpsDepositStatus.ts +++ b/app/components/UI/Perps/hooks/usePerpsDepositStatus.ts @@ -12,7 +12,6 @@ import { ARBITRUM_MAINNET_CHAIN_ID_HEX, USDC_ARBITRUM_MAINNET_ADDRESS, } from '../constants/hyperLiquidConfig'; -import { getStreamManagerInstance } from '../providers/PerpsStreamManager'; import { usePerpsLiveAccount } from './stream/usePerpsLiveAccount'; import usePerpsToasts from './usePerpsToasts'; import { usePerpsTrading } from './usePerpsTrading'; @@ -76,16 +75,9 @@ export const usePerpsDepositStatus = () => { metamaskPay?.tokenAddress === USDC_ARBITRUM_MAINNET_ADDRESS; if ( - (transactionMeta.type === TransactionType.perpsDeposit || - transactionMeta.type === TransactionType.perpsDepositAndOrder) && + transactionMeta.type === TransactionType.perpsDeposit && transactionMeta.status === TransactionStatus.approved ) { - // Skip showing toast if a component is actively handling deposit toasts - // (e.g., PerpsOrderView handles its own deposit toasts) - if (getStreamManagerInstance().hasActiveDepositHandler()) { - return; - } - expectingDepositRef.current = true; prevAvailableBalanceRef.current = liveAccount?.availableBalance || '0'; diff --git a/app/components/UI/Perps/hooks/usePerpsOrderDepositTracking.ts b/app/components/UI/Perps/hooks/usePerpsOrderDepositTracking.ts index 6cb5c5cad3d..996dbcb20d4 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderDepositTracking.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderDepositTracking.ts @@ -7,8 +7,7 @@ import { useCallback, useContext } from 'react'; import Engine from '../../../../core/Engine'; import { ToastContext } from '../../../../component-library/components/Toast'; import { strings } from '../../../../../locales/i18n'; -import { getStreamManagerInstance } from '../providers/PerpsStreamManager'; -import { usePerpsLiveAccount } from './stream/usePerpsLiveAccount'; +import { PERPS_CONSTANTS } from '../constants/perpsConfig'; import usePerpsToasts from './usePerpsToasts'; /** @@ -24,7 +23,6 @@ import usePerpsToasts from './usePerpsToasts'; * This ensures the order is placed automatically after the deposit completes. */ export const usePerpsOrderDepositTracking = () => { - const { account } = usePerpsLiveAccount(); const { showToast, PerpsToastOptions } = usePerpsToasts(); const { toastRef } = useContext(ToastContext); @@ -50,16 +48,30 @@ export const usePerpsOrderDepositTracking = () => { // Callback to show toast when user confirms the deposit const handleDepositConfirm = useCallback( (transactionMeta: TransactionMeta, callback: () => void) => { - if ( - transactionMeta.type !== TransactionType.perpsDeposit && - transactionMeta.type !== TransactionType.perpsDepositAndOrder - ) { + if (transactionMeta.type !== TransactionType.perpsDepositAndOrder) { return; } - getStreamManagerInstance().setActiveDepositHandler(true); const transactionId = transactionMeta.id; + let cancelTradeRequested = false; showProgressToast(transactionId); + const takingLongerToastOptions = + PerpsToastOptions.accountManagement.deposit.takingLonger; + const cancelTradeOnPress = () => { + cancelTradeRequested = true; + // Replace current toast with "Trade canceled" (don't close first to avoid race) + showToast(PerpsToastOptions.accountManagement.deposit.tradeCanceled); + }; + const depositLongerTimeoutId = setTimeout(() => { + const baseClose = takingLongerToastOptions.closeButtonOptions; + showToast({ + ...takingLongerToastOptions, + closeButtonOptions: baseClose + ? { ...baseClose, onPress: cancelTradeOnPress } + : undefined, + } as Parameters[0]); + }, PERPS_CONSTANTS.DepositTakingLongerToastDelayMs); + // Handle failed transactions const handleTransactionFailed = ({ transactionMeta: failedTransactionMeta, @@ -67,13 +79,10 @@ export const usePerpsOrderDepositTracking = () => { transactionMeta: TransactionMeta; }) => { if ( - failedTransactionMeta?.type === TransactionType.perpsDeposit || failedTransactionMeta?.type === TransactionType.perpsDepositAndOrder ) { if (failedTransactionMeta.id === transactionId) { - // Unmark active handler so usePerpsDepositStatus can handle it if needed - getStreamManagerInstance().setActiveDepositHandler(false); - // Close the depositing toast + clearTimeout(depositLongerTimeoutId); toastRef?.current?.closeToast(); showToast(PerpsToastOptions.accountManagement.deposit.error); } @@ -89,19 +98,11 @@ export const usePerpsOrderDepositTracking = () => { updatedTransactionMeta.id === transactionId && updatedTransactionMeta.status === TransactionStatus.confirmed ) { - // Unmark active handler so usePerpsDepositStatus can handle it if needed - + clearTimeout(depositLongerTimeoutId); toastRef?.current?.closeToast(); - showToast( - PerpsToastOptions.accountManagement.deposit.success( - account?.availableBalance?.toString() || '0', - ), - ); - setTimeout( - () => getStreamManagerInstance().setActiveDepositHandler(false), - 1000, - ); - callback?.(); + if (!cancelTradeRequested) { + callback?.(); + } } }; @@ -117,7 +118,6 @@ export const usePerpsOrderDepositTracking = () => { [ showToast, toastRef, - account?.availableBalance, showProgressToast, PerpsToastOptions.accountManagement.deposit, ], diff --git a/app/components/UI/Perps/hooks/usePerpsOrderForm.test.ts b/app/components/UI/Perps/hooks/usePerpsOrderForm.test.ts index dd8ac5bbd2f..6c9ab6d4371 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderForm.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderForm.test.ts @@ -469,14 +469,14 @@ describe('usePerpsOrderForm', () => { describe('useMemo and useEffect behavior', () => { it('should not overwrite user input when dependencies change', async () => { - // Arrange - Start with sufficient balance + // Arrange - Start with balance high enough that max >= 999 (e.g. 334 * 3x = 1002) const mockAccount = { account: { - availableBalance: '10', // $10 balance = $30 max with 3x leverage + availableBalance: '334', marginUsed: '0', unrealizedPnl: '0', returnOnEquity: '0', - totalBalance: '10', + totalBalance: '334', }, isInitialLoading: false, }; @@ -491,19 +491,22 @@ describe('usePerpsOrderForm', () => { TRADING_DEFAULTS.amount.mainnet.toString(), ); - // Act - User changes the amount + // Act - User changes the amount (within current max) act(() => { result.current.setAmount('999'); }); expect(result.current.orderForm.amount).toBe('999'); - // Act - Change the available balance to trigger useMemo recalculation - mockAccount.account.availableBalance = '1'; // This would normally trigger a different initialAmountValue + // Act - Change the available balance so the new max is below user's amount + mockAccount.account.availableBalance = '1'; // $1 balance → max order size drops below 999 mockUsePerpsLiveAccount.mockReturnValue(mockAccount); rerender({}); - // Assert - Amount should not be overwritten due to hasSetInitialAmount ref - expect(result.current.orderForm.amount).toBe('999'); + // Assert - Amount should be clamped to the new max when effective balance drops (payment token change or balance update) + expect(Number(result.current.orderForm.amount)).toBeLessThanOrEqual( + result.current.maxPossibleAmount, + ); + expect(result.current.orderForm.amount).not.toBe('999'); }); it('should use useMemo for initialAmountValue calculation', () => { diff --git a/app/components/UI/Perps/hooks/usePerpsOrderForm.ts b/app/components/UI/Perps/hooks/usePerpsOrderForm.ts index c8d9fa5c2c8..25c23695220 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderForm.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderForm.ts @@ -23,6 +23,8 @@ interface UsePerpsOrderFormParams { initialAmount?: string; initialLeverage?: number; initialType?: OrderType; + /** When paying with a custom token, the selected token amount in USD; used to cap maxPossibleAmount and handlers */ + effectiveAvailableBalance?: number; } export interface UsePerpsOrderFormReturn { @@ -40,6 +42,8 @@ export interface UsePerpsOrderFormReturn { handleMaxAmount: () => void; handleMinAmount: () => void; maxPossibleAmount: number; + /** Balance to use for validation and UI (Perps balance or selected token amount in USD when paying with custom token) */ + balanceForValidation: number; } /** @@ -55,6 +59,7 @@ export function usePerpsOrderForm( initialAmount, initialLeverage, initialType = 'market', + effectiveAvailableBalance: effectiveAvailableBalanceParam, } = params; const currentNetwork = usePerpsNetwork(); @@ -84,11 +89,15 @@ export function usePerpsOrderForm( selectPendingTradeConfiguration(state, initialAsset), ); - // Get available balance from live account data const availableBalance = Number.parseFloat( - account?.availableBalance?.toString() || '0', + effectiveAvailableBalanceParam != null + ? effectiveAvailableBalanceParam.toString() + : (account?.availableBalance?.toString() ?? '0'), ); + // When paying with a custom token, use selected token amount in USD (including 0); otherwise use Perps balance + const balanceForMax = effectiveAvailableBalanceParam ?? availableBalance; + // Determine default amount based on network const defaultAmount = currentNetwork === 'mainnet' @@ -121,7 +130,7 @@ export function usePerpsOrderForm( } const tempMaxAmount = getMaxAllowedAmount({ - availableBalance, + availableBalance: balanceForMax, assetPrice: Number.parseFloat(currentPrice.price), assetSzDecimals: marketData?.szDecimals ?? 6, leverage: defaultLeverage, // Use default leverage for initial calculation @@ -138,7 +147,7 @@ export function usePerpsOrderForm( }, [ initialAmount, pendingConfig?.amount, - availableBalance, + balanceForMax, defaultAmount, currentPrice?.price, marketData?.szDecimals, @@ -169,17 +178,17 @@ export function usePerpsOrderForm( type: defaultOrderType, }); - // Calculate the maximum possible amount based on available balance and current leverage + // Calculate the maximum possible amount; when paying with custom token, capped by selected token amount in USD const maxPossibleAmount = useMemo( () => getMaxAllowedAmount({ - availableBalance, + availableBalance: balanceForMax, assetPrice: Number.parseFloat(currentPrice?.price) || 0, assetSzDecimals: marketData?.szDecimals ?? 6, leverage: orderForm.leverage, // Use current leverage instead of default }), [ - availableBalance, + balanceForMax, currentPrice?.price, marketData?.szDecimals, orderForm.leverage, // Include current leverage in dependencies @@ -239,6 +248,17 @@ export function usePerpsOrderForm( } }, [existingPositionLeverage, initialLeverage, orderForm.leverage]); + // When user changes payment token (or effective balance drops), reset amount to MAX if current amount exceeds new max + useEffect(() => { + const current = Number.parseFloat(orderForm.amount || '0'); + if (maxPossibleAmount >= 0 && current > maxPossibleAmount) { + setOrderForm((prev) => ({ + ...prev, + amount: String(Math.floor(maxPossibleAmount)), + })); + } + }, [balanceForMax, maxPossibleAmount, orderForm.amount]); + // Update entire form const updateOrderForm = (updates: Partial) => { setOrderForm((prev) => ({ ...prev, ...updates })); @@ -293,26 +313,26 @@ export function usePerpsOrderForm( setOrderForm((prev) => ({ ...prev, type })); }; - // Handle percentage-based amount selection + // Handle percentage-based amount selection (respects custom token amount when set) const handlePercentageAmount = useCallback( (percentage: number) => { - if (availableBalance === 0) return; + if (balanceForMax === 0) return; const newAmount = Math.floor( - availableBalance * orderForm.leverage * percentage, + balanceForMax * orderForm.leverage * percentage, ).toString(); setOrderForm((prev) => ({ ...prev, amount: newAmount })); }, - [availableBalance, orderForm.leverage], + [balanceForMax, orderForm.leverage], ); - // Handle max amount selection + // Handle max amount selection (respects custom token amount when set) const handleMaxAmount = useCallback(() => { - if (availableBalance === 0) return; + if (balanceForMax === 0) return; setOrderForm((prev) => ({ ...prev, - amount: Math.floor(availableBalance * prev.leverage).toString(), + amount: Math.floor(balanceForMax * prev.leverage).toString(), })); - }, [availableBalance]); + }, [balanceForMax]); // Handle min amount selection const handleMinAmount = useCallback(() => { @@ -341,5 +361,6 @@ export function usePerpsOrderForm( handleMaxAmount, handleMinAmount, maxPossibleAmount, + balanceForValidation: balanceForMax, }; } diff --git a/app/components/UI/Perps/hooks/usePerpsPaymentToken.ts b/app/components/UI/Perps/hooks/usePerpsPaymentToken.ts new file mode 100644 index 00000000000..c17ce4582b7 --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsPaymentToken.ts @@ -0,0 +1,27 @@ +import { Hex } from '@metamask/utils'; +import { useCallback } from 'react'; +import { AssetType } from '../../../Views/confirmations/types/token'; +import { useTransactionPayToken } from '../../../Views/confirmations/hooks/pay/useTransactionPayToken'; +import Engine from '../../../../core/Engine'; + +export interface UsePerpsPaymentTokenResult { + onPaymentTokenChange: (token: AssetType | null) => void; +} + +export function usePerpsPaymentToken(): UsePerpsPaymentTokenResult { + const { setPayToken } = useTransactionPayToken(); + + const onPaymentTokenChange = useCallback( + (token: AssetType | null) => { + Engine.context.PerpsController?.setSelectedPaymentToken?.(token); + if (token) { + setPayToken({ + address: token.address as Hex, + chainId: token.chainId as Hex, + }); + } + }, + [setPayToken], + ); + return { onPaymentTokenChange }; +} diff --git a/app/components/UI/Perps/hooks/usePerpsToasts.tsx b/app/components/UI/Perps/hooks/usePerpsToasts.tsx index 3025e73fdb6..6033469478d 100644 --- a/app/components/UI/Perps/hooks/usePerpsToasts.tsx +++ b/app/components/UI/Perps/hooks/usePerpsToasts.tsx @@ -45,6 +45,8 @@ export interface PerpsToastOptionsConfig { processingTimeInSeconds: number | undefined, transactionId: string, ) => PerpsToastOptions; + takingLonger: PerpsToastOptions; + tradeCanceled: PerpsToastOptions; error: PerpsToastOptions; }; oneClickTrade: { @@ -271,6 +273,14 @@ const usePerpsToasts = (): { backgroundColor: theme.colors.accent01.light, hapticsType: NotificationFeedbackType.Error, }, + warning: { + ...(PERPS_TOASTS_DEFAULT_OPTIONS as PerpsToastOptions), + variant: ToastVariants.Icon, + iconName: IconName.Warning, + iconColor: theme.colors.warning.default, + backgroundColor: theme.colors.warning.muted, + hapticsType: NotificationFeedbackType.Warning, + }, }), [theme], ); @@ -394,6 +404,42 @@ const usePerpsToasts = (): { closeButtonOptions, }; }, + takingLonger: { + ...perpsBaseToastOptions.warning, + labelOptions: getPerpsToastLabels( + strings('perps.deposit.deposit_taking_longer'), + ), + hasNoTimeout: true, + closeButtonOptions: { + label: ( + + {strings('perps.deposit.cancel_trade')} + + ), + variant: ButtonVariants.Secondary, + style: { backgroundColor: theme.colors.background.muted }, + onPress: () => { + /* no-op */ + }, + }, + }, + tradeCanceled: { + ...(PERPS_TOASTS_DEFAULT_OPTIONS as PerpsToastOptions), + variant: ToastVariants.Icon, + iconName: IconName.Warning, + iconColor: theme.colors.error.default, + backgroundColor: theme.colors.error.muted, + hapticsType: NotificationFeedbackType.Warning, + labelOptions: getPerpsToastLabels( + strings('perps.deposit.trade_canceled'), + ), + descriptionOptions: { + description: strings('perps.deposit.funds_returned_to_account'), + }, + }, error: { ...perpsBaseToastOptions.error, labelOptions: getPerpsToastLabels( @@ -929,8 +975,11 @@ const usePerpsToasts = (): { perpsBaseToastOptions.inProgress, perpsBaseToastOptions.info, perpsBaseToastOptions.success, + perpsBaseToastOptions.warning, perpsToastButtonOptions, + theme.colors.background.muted, theme.colors.error.default, + theme.colors.error.muted, theme.colors.success.default, ], ); diff --git a/app/components/UI/Perps/hooks/usePerpsTransactionHistory.test.ts b/app/components/UI/Perps/hooks/usePerpsTransactionHistory.test.ts index de025ad912a..7384297fdb6 100644 --- a/app/components/UI/Perps/hooks/usePerpsTransactionHistory.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsTransactionHistory.test.ts @@ -928,4 +928,114 @@ describe('usePerpsTransactionHistory', () => { ]); }); }); + + describe('connection state transitions', () => { + it('triggers fetch when skipInitialFetch transitions from true to false', async () => { + // Reset mocks to track calls clearly + mockProvider.getOrderFills.mockClear(); + mockProvider.getOrders.mockClear(); + mockProvider.getFunding.mockClear(); + + // Start with skipInitialFetch: true (simulating not connected state) + const { rerender } = renderHook( + ({ skipInitialFetch }) => + usePerpsTransactionHistory({ skipInitialFetch }), + { initialProps: { skipInitialFetch: true } }, + ); + + // Verify no fetch was made while skipInitialFetch is true + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(mockProvider.getOrderFills).not.toHaveBeenCalled(); + + // Clear mocks to track only the new calls + mockProvider.getOrderFills.mockClear(); + mockProvider.getOrders.mockClear(); + mockProvider.getFunding.mockClear(); + + // Transition to skipInitialFetch: false (simulating connection established) + rerender({ skipInitialFetch: false }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + // Verify fetch was triggered after the transition + expect(mockProvider.getOrderFills).toHaveBeenCalledTimes(1); + expect(mockProvider.getOrders).toHaveBeenCalledTimes(1); + expect(mockProvider.getFunding).toHaveBeenCalledTimes(1); + }); + + it('does not duplicate fetch when skipInitialFetch starts as false', async () => { + // Reset mocks to track calls clearly + mockProvider.getOrderFills.mockClear(); + mockProvider.getOrders.mockClear(); + mockProvider.getFunding.mockClear(); + + // Start with skipInitialFetch: false (already connected) + renderHook(() => usePerpsTransactionHistory({ skipInitialFetch: false })); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + // Verify fetch was made exactly once (no duplicate) + expect(mockProvider.getOrderFills).toHaveBeenCalledTimes(1); + expect(mockProvider.getOrders).toHaveBeenCalledTimes(1); + expect(mockProvider.getFunding).toHaveBeenCalledTimes(1); + }); + + it('fetches once per true-to-false transition during rapid state changes', async () => { + // Reset mocks to track calls clearly + mockProvider.getOrderFills.mockClear(); + mockProvider.getOrders.mockClear(); + mockProvider.getFunding.mockClear(); + + // Start with skipInitialFetch: true + const { rerender } = renderHook( + ({ skipInitialFetch }) => + usePerpsTransactionHistory({ skipInitialFetch }), + { initialProps: { skipInitialFetch: true } }, + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + // No fetch yet + expect(mockProvider.getOrderFills).not.toHaveBeenCalled(); + + // Transition to connected + rerender({ skipInitialFetch: false }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + // First fetch + expect(mockProvider.getOrderFills).toHaveBeenCalledTimes(1); + + // Clear and transition back to disconnected + mockProvider.getOrderFills.mockClear(); + rerender({ skipInitialFetch: true }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + // No additional fetch while disconnected + expect(mockProvider.getOrderFills).not.toHaveBeenCalled(); + + // Reconnect - should fetch again + rerender({ skipInitialFetch: false }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + // Should fetch on reconnection + expect(mockProvider.getOrderFills).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/app/components/UI/Perps/hooks/usePerpsTransactionHistory.ts b/app/components/UI/Perps/hooks/usePerpsTransactionHistory.ts index 01189c69591..aad2523b431 100644 --- a/app/components/UI/Perps/hooks/usePerpsTransactionHistory.ts +++ b/app/components/UI/Perps/hooks/usePerpsTransactionHistory.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import usePrevious from '../../../hooks/usePrevious'; import { BigNumber } from 'bignumber.js'; import Engine from '../../../../core/Engine'; import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; @@ -58,6 +59,8 @@ export const usePerpsTransactionHistory = ({ const userHistoryRef = useRef(userHistory); // Track if initial fetch has been done to prevent duplicate fetches const initialFetchDone = useRef(false); + // Track previous skipInitialFetch value to detect connection state transitions + const prevSkipInitialFetch = usePrevious(skipInitialFetch); useEffect(() => { userHistoryRef.current = userHistory; }, [userHistory]); @@ -168,11 +171,21 @@ export const usePerpsTransactionHistory = ({ }, [fetchAllTransactions, refetchUserHistory]); useEffect(() => { - if (!skipInitialFetch && !initialFetchDone.current) { + // Detect transition from skipping (not connected) to not skipping (connected) + // This fixes the case where the component mounts before connection is established + const justBecameConnected = prevSkipInitialFetch && !skipInitialFetch; + + // Trigger fetch if: + // 1. Not skipping AND haven't fetched yet (normal initial fetch) + // 2. Connection just became available (transition from disconnected to connected) + if ( + !skipInitialFetch && + (!initialFetchDone.current || justBecameConnected) + ) { initialFetchDone.current = true; refetch(); } - }, [skipInitialFetch, refetch]); + }, [skipInitialFetch, prevSkipInitialFetch, refetch]); // Combine loading states const combinedIsLoading = useMemo( diff --git a/app/components/UI/Perps/index.ts b/app/components/UI/Perps/index.ts index 11428d89745..ec7b367fb29 100644 --- a/app/components/UI/Perps/index.ts +++ b/app/components/UI/Perps/index.ts @@ -8,4 +8,6 @@ export { } from './selectors/featureFlags'; export { PERPS_CONSTANTS } from './constants/perpsConfig'; +export { usePerpsPaymentToken } from './hooks/usePerpsPaymentToken'; + export * from './types/perps-types'; diff --git a/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx b/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx index 5d6bdd70783..e69e9522290 100644 --- a/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx +++ b/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx @@ -9,7 +9,12 @@ import { import Engine from '../../../../core/Engine'; import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; import Logger from '../../../../util/Logger'; -import type { PriceUpdate, PerpsMarketData, Order } from '../controllers/types'; +import type { + PriceUpdate, + PerpsMarketData, + Order, + AccountState, +} from '../controllers/types'; import { PerpsConnectionManager } from '../services/PerpsConnectionManager'; jest.mock('../../../../core/Engine'); @@ -764,6 +769,37 @@ describe('PerpsStreamManager', () => { cleanupPrewarmSpy.mockRestore(); }); + it('notifies subscriber with null when account subscription callback receives null', async () => { + let accountCallback: ((account: AccountState | null) => void) | null = + null; + mockSubscribeToAccount.mockImplementation( + (params: { callback: (account: AccountState | null) => void }) => { + accountCallback = params.callback; + return jest.fn(); + }, + ); + + const subscriberCallback = jest.fn(); + const unsubscribe = testStreamManager.account.subscribe({ + callback: subscriberCallback, + throttleMs: 0, + }); + + await waitFor(() => { + expect(mockSubscribeToAccount).toHaveBeenCalled(); + }); + + act(() => { + accountCallback?.(null); + }); + + expect(subscriberCallback).toHaveBeenCalledTimes(1); + expect(subscriberCallback).toHaveBeenCalledWith(null); + expect(mockLogger.error).not.toHaveBeenCalled(); + + unsubscribe(); + }); + it('should reset all prewarm state when clearing price cache', async () => { // Mock market data to populate allMarketSymbols const mockGetMarketDataWithPrices = jest.fn(); @@ -2921,20 +2957,4 @@ describe('PerpsStreamManager', () => { pricesDisconnect.mockRestore(); }); }); - - describe('Deposit Handler Management', () => { - it('sets active deposit handler state', () => { - expect(testStreamManager.hasActiveDepositHandler()).toBe(false); - - testStreamManager.setActiveDepositHandler(true); - expect(testStreamManager.hasActiveDepositHandler()).toBe(true); - - testStreamManager.setActiveDepositHandler(false); - expect(testStreamManager.hasActiveDepositHandler()).toBe(false); - }); - - it('returns false by default when no active deposit handler is set', () => { - expect(testStreamManager.hasActiveDepositHandler()).toBe(false); - }); - }); }); diff --git a/app/components/UI/Perps/providers/PerpsStreamManager.tsx b/app/components/UI/Perps/providers/PerpsStreamManager.tsx index eaec6098be5..c877bf14240 100644 --- a/app/components/UI/Perps/providers/PerpsStreamManager.tsx +++ b/app/components/UI/Perps/providers/PerpsStreamManager.tsx @@ -915,7 +915,7 @@ class AccountStreamChannel extends StreamChannel { this.wsConnectionStartTime = performance.now(); this.wsSubscription = Engine.context.PerpsController.subscribeToAccount({ - callback: (account: AccountState) => { + callback: (account: AccountState | null) => { // Validate account context const currentAccount = getEvmAccountFromSelectedAccountGroup()?.address || null; @@ -1387,27 +1387,6 @@ export class PerpsStreamManager { // public readonly funding = new FundingStreamChannel(); // public readonly trades = new TradeStreamChannel(); - // UI coordination: Track if a component is actively handling deposit toasts - // This prevents duplicate toasts between usePerpsDepositStatus and usePerpsOrderDepositTracking - private activeDepositHandler = false; - - /** - * Set whether a component is actively handling deposit toasts - * Used by PerpsOrderView to prevent duplicate toasts from usePerpsDepositStatus - * @param isActive - Whether a component is actively handling deposit toasts - */ - public setActiveDepositHandler(isActive: boolean): void { - this.activeDepositHandler = isActive; - } - - /** - * Check if a component is actively handling deposit toasts - * @returns true if a component is actively handling deposit toasts - */ - public hasActiveDepositHandler(): boolean { - return this.activeDepositHandler; - } - /** * Force reconnection of all stream channels after WebSocket reconnection * Disconnects all channels and reconnects those with active subscribers diff --git a/app/components/UI/Perps/routes/index.tsx b/app/components/UI/Perps/routes/index.tsx index bde1f15ba05..fb199d53ab6 100644 --- a/app/components/UI/Perps/routes/index.tsx +++ b/app/components/UI/Perps/routes/index.tsx @@ -36,7 +36,6 @@ import ActivityView from '../../../Views/ActivityView'; import PerpsStreamBridge from '../components/PerpsStreamBridge'; import { HIP3DebugView } from '../Debug'; import PerpsCrossMarginWarningBottomSheet from '../components/PerpsCrossMarginWarningBottomSheet'; -import { useTheme } from '../../../../util/theme'; import { RouteProp, useRoute } from '@react-navigation/native'; import { CONFIRMATION_HEADER_CONFIG } from '../constants/perpsConfig'; @@ -66,22 +65,13 @@ const PerpsConfirmScreen = ( route: RouteProp; }, ) => { - const theme = useTheme(); const params = useRoute>(); const showPerpsHeader = params?.params?.showPerpsHeader ?? CONFIRMATION_HEADER_CONFIG.DefaultShowPerpsHeader; - return ( - - ); + return ; }; const PerpsModalStack = () => { diff --git a/app/components/UI/Perps/selectors/perpsController/index.ts b/app/components/UI/Perps/selectors/perpsController/index.ts index 27465462e87..99bfb474a42 100644 --- a/app/components/UI/Perps/selectors/perpsController/index.ts +++ b/app/components/UI/Perps/selectors/perpsController/index.ts @@ -71,6 +71,14 @@ const selectPerpsMarketFilterPreferences = createSelector( (perpsControllerState) => selectMarketFilterPreferences(perpsControllerState), ); +/** + * True when the user selected the synthetic "Perps balance" option (selectedPaymentToken === null). + */ +const selectIsPerpsBalanceSelected = createSelector( + selectPerpsControllerState, + (perpsControllerState) => perpsControllerState?.selectedPaymentToken == null, +); + /** * Selects the current initialization state of the Perps controller. * Used by UI components to determine if operations can be performed. @@ -105,4 +113,5 @@ export { selectPerpsWatchlistMarkets, selectPerpsMarketFilterPreferences, selectPerpsInitializationState, + selectIsPerpsBalanceSelected, }; diff --git a/app/components/UI/Perps/utils/hyperLiquidAdapter.test.ts b/app/components/UI/Perps/utils/hyperLiquidAdapter.test.ts index e4e1690dcaa..c70b4f4de13 100644 --- a/app/components/UI/Perps/utils/hyperLiquidAdapter.test.ts +++ b/app/components/UI/Perps/utils/hyperLiquidAdapter.test.ts @@ -2,6 +2,15 @@ * Unit tests for HyperLiquid SDK adapter utilities */ +// Avoid loading @metamask/swaps-controller (and thus controller-utils logger) in tests +jest.mock('../constants/perpsConfig', () => ({ + DECIMAL_PRECISION_CONFIG: { + MaxPriceDecimals: 6, + MaxSignificantFigures: 5, + FallbackSizeDecimals: 6, + }, +})); + import { adaptOrderToSDK, adaptOrderFromSDK, diff --git a/app/components/UI/Predict/components/PredictActivityDetail/PredictActivityDetail.tsx b/app/components/UI/Predict/components/PredictActivityDetail/PredictActivityDetail.tsx index 80b56f43bef..33c250221a1 100644 --- a/app/components/UI/Predict/components/PredictActivityDetail/PredictActivityDetail.tsx +++ b/app/components/UI/Predict/components/PredictActivityDetail/PredictActivityDetail.tsx @@ -29,7 +29,7 @@ import { BoxJustifyContent, } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import HeaderCenter from '../../../../../component-library/components-temp/HeaderCenter'; +import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; import UsdcIcon from './usdc.svg'; import { PredictActivityDetailsSelectorsIDs } from '../../Predict.testIds'; interface PredictActivityDetailProps {} @@ -374,7 +374,7 @@ const PredictActivityDetails: React.FC = () => { testID={PredictActivityDetailsSelectorsIDs.CONTAINER} > - { expect(queryByText(/Withdraw/i)).not.toBeOnTheScreen(); }); - it('calls deposit function when Add Funds button is pressed', () => { + it('calls deposit function with analytics properties when Add Funds button is pressed', () => { // Arrange const mockDeposit = jest.fn(); mockUsePredictBalance.mockReturnValue({ @@ -318,6 +318,11 @@ describe('PredictBalance', () => { // Assert expect(mockDeposit).toHaveBeenCalledTimes(1); + expect(mockDeposit).toHaveBeenCalledWith({ + analyticsProperties: { + entryPoint: 'homepage_balance', + }, + }); }); it('calls executeGuardedAction when Add Funds button is pressed', () => { diff --git a/app/components/UI/Predict/components/PredictBalance/PredictBalance.tsx b/app/components/UI/Predict/components/PredictBalance/PredictBalance.tsx index d56347af8d9..2e8d49b6d5c 100644 --- a/app/components/UI/Predict/components/PredictBalance/PredictBalance.tsx +++ b/app/components/UI/Predict/components/PredictBalance/PredictBalance.tsx @@ -70,7 +70,11 @@ const PredictBalance: React.FC = ({ onLayout }) => { const handleAddFunds = useCallback(() => { executeGuardedAction( () => { - deposit(); + deposit({ + analyticsProperties: { + entryPoint: PredictEventValues.ENTRY_POINT.HOMEPAGE_BALANCE, + }, + }); }, { attemptedAction: PredictEventValues.ATTEMPTED_ACTION.DEPOSIT }, ); diff --git a/app/components/UI/Predict/constants/eventNames.ts b/app/components/UI/Predict/constants/eventNames.ts index fb9f7cb2050..63cd0b5d8d8 100644 --- a/app/components/UI/Predict/constants/eventNames.ts +++ b/app/components/UI/Predict/constants/eventNames.ts @@ -86,10 +86,14 @@ export const PredictEventValues = { BACKGROUND: 'background', TRENDING_SEARCH: 'trending_search', TRENDING: 'trending', + BUY_PREVIEW: 'buy_preview', }, TRANSACTION_TYPE: { MM_PREDICT_BUY: 'mm_predict_buy', MM_PREDICT_SELL: 'mm_predict_sell', + MM_PREDICT_DEPOSIT: 'mm_predict_deposit', + MM_PREDICT_WITHDRAW: 'mm_predict_withdraw', + MM_PREDICT_CLAIM: 'mm_predict_claim', }, MARKET_TYPE: { BINARY: 'binary', diff --git a/app/components/UI/Predict/constants/flags.ts b/app/components/UI/Predict/constants/flags.ts index 3f3fdf7ff63..d81c3d60b1b 100644 --- a/app/components/UI/Predict/constants/flags.ts +++ b/app/components/UI/Predict/constants/flags.ts @@ -24,6 +24,7 @@ export const DEFAULT_LIVE_SPORTS_FLAG: PredictLiveSportsFlag = { export const DEFAULT_MARKET_HIGHLIGHTS_FLAG: PredictMarketHighlightsFlag = { enabled: false, highlights: [], + minimumVersion: '7.64.0', }; export const DEFAULT_HOT_TAB_FLAG: PredictHotTabFlag = { diff --git a/app/components/UI/Predict/controllers/PredictController.test.ts b/app/components/UI/Predict/controllers/PredictController.test.ts index 03a41eb07c6..b627501f530 100644 --- a/app/components/UI/Predict/controllers/PredictController.test.ts +++ b/app/components/UI/Predict/controllers/PredictController.test.ts @@ -32,6 +32,7 @@ import { PredictControllerMessenger, type PredictControllerState, } from './PredictController'; +import { analytics } from '../../../../util/analytics/analytics'; // Mock the PolymarketProvider and its dependencies jest.mock('../providers/polymarket/PolymarketProvider'); @@ -58,6 +59,7 @@ const DEFAULT_REMOTE_FEATURE_FLAG_STATE = { }, predictMarketHighlights: { enabled: false, + minimumVersion: '0.0.0', highlights: [], }, }, @@ -70,6 +72,11 @@ const DEFAULT_NETWORK_CLIENT = { }, }; +// Mock react-native-device-info for version gating +jest.mock('react-native-device-info', () => ({ + getVersion: jest.fn().mockReturnValue('99.0.0'), +})); + // Mock DevLogger (default export) jest.mock('../../../../core/SDKConnect/utils/DevLogger', () => ({ __esModule: true, @@ -88,6 +95,13 @@ jest.mock('@metamask/controller-utils', () => { }; }); +// Mock analytics module +jest.mock('../../../../util/analytics/analytics', () => ({ + analytics: { + trackEvent: jest.fn(), + }, +})); + type AllPredictControllerMessengerActions = MessengerActions; @@ -1803,11 +1817,16 @@ describe('PredictController', () => { title: `Market ${id}`, category, outcomes: ['YES', 'NO'], + status: 'open', }); const createFlagState = (flag: { enabled: boolean; - highlights: { category: string; markets: string[] }[]; + minimumVersion?: string; + highlights: { + category: string; + markets: string[]; + }[]; }) => ({ remoteFeatureFlags: { predictFeeCollection: { @@ -1821,7 +1840,10 @@ describe('PredictController', () => { enabled: false, leagues: [], }, - predictMarketHighlights: flag, + predictMarketHighlights: { + ...flag, + minimumVersion: flag.minimumVersion ?? '0.0.0', + }, }, cacheTimestamp: Date.now(), }); @@ -2327,6 +2349,137 @@ describe('PredictController', () => { }, ); }); + + it('filters out closed highlighted markets', async () => { + const regularMarkets = [createMockMarket('regular-1')]; + const closedHighlightedMarket = { + ...createMockMarket('highlight-1'), + status: 'closed', + }; + const openHighlightedMarket = { + ...createMockMarket('highlight-2'), + status: 'open', + }; + + await withController( + async ({ controller }) => { + mockPolymarketProvider.getMarkets.mockResolvedValue( + regularMarkets as any, + ); + mockPolymarketProvider.getMarketsByIds.mockResolvedValue([ + closedHighlightedMarket, + openHighlightedMarket, + ] as any); + + const result = await controller.getMarkets({ + providerId: 'polymarket', + category: 'trending', + offset: 0, + }); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe('highlight-2'); + expect(result[1].id).toBe('regular-1'); + expect(result.find((m) => m.id === 'highlight-1')).toBeUndefined(); + }, + { + mocks: { + getRemoteFeatureFlagState: jest.fn().mockReturnValue( + createFlagState({ + enabled: true, + highlights: [ + { + category: 'trending', + markets: ['highlight-1', 'highlight-2'], + }, + ], + }), + ), + }, + }, + ); + }); + + it('filters out resolved highlighted markets', async () => { + const regularMarkets = [createMockMarket('regular-1')]; + const resolvedHighlightedMarket = { + ...createMockMarket('highlight-1'), + status: 'resolved', + }; + + await withController( + async ({ controller }) => { + mockPolymarketProvider.getMarkets.mockResolvedValue( + regularMarkets as any, + ); + mockPolymarketProvider.getMarketsByIds.mockResolvedValue([ + resolvedHighlightedMarket, + ] as any); + + const result = await controller.getMarkets({ + providerId: 'polymarket', + category: 'trending', + offset: 0, + }); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('regular-1'); + }, + { + mocks: { + getRemoteFeatureFlagState: jest.fn().mockReturnValue( + createFlagState({ + enabled: true, + highlights: [ + { + category: 'trending', + markets: ['highlight-1'], + }, + ], + }), + ), + }, + }, + ); + }); + + it('skips highlights when version requirement not met', async () => { + const regularMarkets = [createMockMarket('regular-1')]; + + await withController( + async ({ controller }) => { + mockPolymarketProvider.getMarkets.mockResolvedValue( + regularMarkets as any, + ); + + const result = await controller.getMarkets({ + providerId: 'polymarket', + category: 'trending', + offset: 0, + }); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('regular-1'); + expect(mockPolymarketProvider.getMarketsByIds).not.toHaveBeenCalled(); + }, + { + mocks: { + getRemoteFeatureFlagState: jest.fn().mockReturnValue( + createFlagState({ + enabled: true, + minimumVersion: '999.0.0', + highlights: [ + { + category: 'trending', + markets: ['highlight-1'], + }, + ], + }), + ), + }, + }, + ); + }); }); describe('updateStateForTesting', () => { @@ -5783,4 +5936,86 @@ describe('PredictController', () => { }); }); }); + + describe('Analytics Tracking', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls analytics.trackEvent for trackPredictOrderEvent', async () => { + await withController(async ({ controller }) => { + await controller.trackPredictOrderEvent({ + status: 'succeeded', + analyticsProperties: { marketId: 'test' }, + providerId: 'polymarket', + }); + expect(analytics.trackEvent).toHaveBeenCalledTimes(1); + }); + }); + + it('does not call analytics.trackEvent when analyticsProperties is missing for trackPredictOrderEvent', async () => { + await withController(async ({ controller }) => { + await controller.trackPredictOrderEvent({ + status: 'succeeded', + providerId: 'polymarket', + }); + expect(analytics.trackEvent).not.toHaveBeenCalled(); + }); + }); + + it('calls analytics.trackEvent for trackMarketDetailsOpened', () => { + withController(({ controller }) => { + controller.trackMarketDetailsOpened({ + marketId: 'test', + marketTitle: 'test', + entryPoint: 'test', + marketDetailsViewed: 'test', + }); + expect(analytics.trackEvent).toHaveBeenCalledTimes(1); + }); + }); + + it('calls analytics.trackEvent for trackPositionViewed', () => { + withController(({ controller }) => { + controller.trackPositionViewed({ openPositionsCount: 5 }); + expect(analytics.trackEvent).toHaveBeenCalledTimes(1); + }); + }); + + it('calls analytics.trackEvent for trackActivityViewed', () => { + withController(({ controller }) => { + controller.trackActivityViewed({ activityType: 'all' }); + expect(analytics.trackEvent).toHaveBeenCalledTimes(1); + }); + }); + + it('calls analytics.trackEvent for trackGeoBlockTriggered', () => { + withController(({ controller }) => { + controller.trackGeoBlockTriggered({ + providerId: 'polymarket', + attemptedAction: 'deposit', + }); + expect(analytics.trackEvent).toHaveBeenCalledTimes(1); + }); + }); + + it('calls analytics.trackEvent for trackFeedViewed', () => { + withController(({ controller }) => { + controller.trackFeedViewed({ + sessionId: 'test', + feedTab: 'test', + numPagesViewed: 1, + sessionTime: 1000, + }); + expect(analytics.trackEvent).toHaveBeenCalledTimes(1); + }); + }); + + it('calls analytics.trackEvent for trackShareAction', () => { + withController(({ controller }) => { + controller.trackShareAction({ status: 'success' }); + expect(analytics.trackEvent).toHaveBeenCalledTimes(1); + }); + }); + }); }); diff --git a/app/components/UI/Predict/controllers/PredictController.ts b/app/components/UI/Predict/controllers/PredictController.ts index 4e9c5f23a3a..a4e565eb648 100644 --- a/app/components/UI/Predict/controllers/PredictController.ts +++ b/app/components/UI/Predict/controllers/PredictController.ts @@ -36,8 +36,9 @@ import { } from '@metamask/remote-feature-flag-controller'; import { Hex, hexToNumber, numberToHex } from '@metamask/utils'; import performance from 'react-native-performance'; -import { MetaMetrics, MetaMetricsEvents } from '../../../../core/Analytics'; -import { MetricsEventBuilder } from '../../../../core/Analytics/MetricsEventBuilder'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { AnalyticsEventBuilder } from '../../../../util/analytics/AnalyticsEventBuilder'; +import { analytics } from '../../../../util/analytics/analytics'; import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; import Logger, { type LoggerErrorOptions } from '../../../../util/Logger'; import { @@ -106,6 +107,10 @@ import { PredictLiveSportsFlag, PredictMarketHighlightsFlag, } from '../types/flags'; +import { + VersionGatedFeatureFlag, + validatedVersionGatedFeatureFlag, +} from '../../../../util/remoteFeatureFlag'; /** * State shape for PredictController @@ -517,11 +522,19 @@ export class PredictController extends BaseController< ? filterSupportedLeagues(liveSportsFlag.leagues ?? []) : []; - const marketHighlightsFlag = - (remoteFeatureFlagState.remoteFeatureFlags - .predictMarketHighlights as unknown as - | PredictMarketHighlightsFlag - | undefined) ?? DEFAULT_MARKET_HIGHLIGHTS_FLAG; + const rawMarketHighlightsFlag = remoteFeatureFlagState.remoteFeatureFlags + .predictMarketHighlights as unknown as + | PredictMarketHighlightsFlag + | undefined; + + const isHighlightsFlagValid = validatedVersionGatedFeatureFlag( + rawMarketHighlightsFlag as unknown as VersionGatedFeatureFlag, + ); + + const marketHighlightsFlag: PredictMarketHighlightsFlag = + isHighlightsFlagValid && rawMarketHighlightsFlag + ? rawMarketHighlightsFlag + : DEFAULT_MARKET_HIGHLIGHTS_FLAG; const paramsWithLiveSports = { ...params, liveSportsLeagues }; @@ -537,10 +550,7 @@ export class PredictController extends BaseController< const isFirstPage = !params.offset || params.offset === 0; const shouldFetchHighlights = - marketHighlightsFlag.enabled && - isFirstPage && - params.category && - !params.q; + isHighlightsFlagValid && isFirstPage && params.category && !params.q; if (shouldFetchHighlights) { const highlightedMarketIds = @@ -553,12 +563,16 @@ export class PredictController extends BaseController< params.providerId ?? 'polymarket', ); - const highlightedMarkets = + const fetchedHighlightedMarkets = (await provider?.getMarketsByIds?.( highlightedMarketIds, liveSportsLeagues, )) ?? []; + const highlightedMarkets = fetchedHighlightedMarkets.filter( + (market) => market.status === 'open', + ); + const highlightedIdSet = new Set(highlightedMarkets.map((m) => m.id)); markets = markets.filter( (market) => !highlightedIdSet.has(market.id), @@ -1210,8 +1224,8 @@ export class PredictController extends BaseController< sensitiveProperties, }); - MetaMetrics.getInstance().trackEvent( - MetricsEventBuilder.createEventBuilder( + analytics.trackEvent( + AnalyticsEventBuilder.createEventBuilder( MetaMetricsEvents.PREDICT_TRADE_TRANSACTION, ) .addProperties(regularProperties) @@ -1287,8 +1301,8 @@ export class PredictController extends BaseController< analyticsProperties, }); - MetaMetrics.getInstance().trackEvent( - MetricsEventBuilder.createEventBuilder( + analytics.trackEvent( + AnalyticsEventBuilder.createEventBuilder( MetaMetricsEvents.PREDICT_MARKET_DETAILS_OPENED, ) .addProperties(analyticsProperties) @@ -1313,8 +1327,8 @@ export class PredictController extends BaseController< analyticsProperties, }); - MetaMetrics.getInstance().trackEvent( - MetricsEventBuilder.createEventBuilder( + analytics.trackEvent( + AnalyticsEventBuilder.createEventBuilder( MetaMetricsEvents.PREDICT_POSITION_VIEWED, ) .addProperties(analyticsProperties) @@ -1335,8 +1349,8 @@ export class PredictController extends BaseController< analyticsProperties, }); - MetaMetrics.getInstance().trackEvent( - MetricsEventBuilder.createEventBuilder( + analytics.trackEvent( + AnalyticsEventBuilder.createEventBuilder( MetaMetricsEvents.PREDICT_ACTIVITY_VIEWED, ) .addProperties(analyticsProperties) @@ -1364,8 +1378,8 @@ export class PredictController extends BaseController< analyticsProperties, }); - MetaMetrics.getInstance().trackEvent( - MetricsEventBuilder.createEventBuilder( + analytics.trackEvent( + AnalyticsEventBuilder.createEventBuilder( MetaMetricsEvents.PREDICT_GEO_BLOCKED_TRIGGERED, ) .addProperties(analyticsProperties) @@ -1413,8 +1427,8 @@ export class PredictController extends BaseController< isSessionEnd, }); - MetaMetrics.getInstance().trackEvent( - MetricsEventBuilder.createEventBuilder( + analytics.trackEvent( + AnalyticsEventBuilder.createEventBuilder( MetaMetricsEvents.PREDICT_FEED_VIEWED, ) .addProperties(analyticsProperties) @@ -1449,8 +1463,8 @@ export class PredictController extends BaseController< analyticsProperties, }); - MetaMetrics.getInstance().trackEvent( - MetricsEventBuilder.createEventBuilder(MetaMetricsEvents.SHARE_ACTION) + analytics.trackEvent( + AnalyticsEventBuilder.createEventBuilder(MetaMetricsEvents.SHARE_ACTION) .addProperties(analyticsProperties) .build(), ); diff --git a/app/components/UI/Predict/hooks/usePredictDeposit.test.ts b/app/components/UI/Predict/hooks/usePredictDeposit.test.ts index 452b4bf2348..7903a445769 100644 --- a/app/components/UI/Predict/hooks/usePredictDeposit.test.ts +++ b/app/components/UI/Predict/hooks/usePredictDeposit.test.ts @@ -19,6 +19,7 @@ jest.mock('../../../../core/Engine', () => ({ context: { PredictController: { depositWithConfirmation: jest.fn(), + trackPredictOrderEvent: jest.fn(), }, AccountTreeController: { getAccountsFromSelectedAccountGroup: jest.fn(() => [ @@ -215,6 +216,9 @@ describe('usePredictDeposit', () => { ( Engine.context.PredictController.depositWithConfirmation as jest.Mock ).mockClear(); + ( + Engine.context.PredictController.trackPredictOrderEvent as jest.Mock + ).mockClear(); }); afterEach(() => { @@ -844,4 +848,113 @@ describe('usePredictDeposit', () => { consoleErrorSpy.mockRestore(); }); }); + + describe('analytics tracking', () => { + it('tracks analytics event when analyticsProperties is provided', async () => { + ( + Engine.context.PredictController.depositWithConfirmation as jest.Mock + ).mockResolvedValue({ + success: true, + response: { batchId: 'batch-123' }, + }); + const { result } = setupUsePredictDepositTest(); + + await result.current.deposit({ + amountUsd: 100, + analyticsProperties: { + entryPoint: 'homepage_balance', + }, + }); + + expect( + Engine.context.PredictController.trackPredictOrderEvent, + ).toHaveBeenCalledWith({ + status: 'initiated', + providerId: 'polymarket', + amountUsd: 100, + analyticsProperties: { + entryPoint: 'homepage_balance', + transactionType: 'mm_predict_deposit', + }, + }); + }); + + it('does not track analytics event when analyticsProperties is not provided', async () => { + ( + Engine.context.PredictController.depositWithConfirmation as jest.Mock + ).mockResolvedValue({ + success: true, + response: { batchId: 'batch-123' }, + }); + const { result } = setupUsePredictDepositTest(); + + await result.current.deposit(); + + expect( + Engine.context.PredictController.trackPredictOrderEvent, + ).not.toHaveBeenCalled(); + }); + + it('tracks analytics event with custom providerId', async () => { + ( + Engine.context.PredictController.depositWithConfirmation as jest.Mock + ).mockResolvedValue({ + success: true, + response: { batchId: 'batch-123' }, + }); + const { result } = setupUsePredictDepositTest( + {}, + { providerId: 'custom-provider' }, + ); + + await result.current.deposit({ + amountUsd: 50, + analyticsProperties: { + entryPoint: 'buy_preview', + marketId: 'market-123', + }, + }); + + expect( + Engine.context.PredictController.trackPredictOrderEvent, + ).toHaveBeenCalledWith({ + status: 'initiated', + providerId: 'custom-provider', + amountUsd: 50, + analyticsProperties: { + entryPoint: 'buy_preview', + marketId: 'market-123', + transactionType: 'mm_predict_deposit', + }, + }); + }); + + it('tracks analytics event without amountUsd when not provided', async () => { + ( + Engine.context.PredictController.depositWithConfirmation as jest.Mock + ).mockResolvedValue({ + success: true, + response: { batchId: 'batch-123' }, + }); + const { result } = setupUsePredictDepositTest(); + + await result.current.deposit({ + analyticsProperties: { + entryPoint: 'homepage_balance', + }, + }); + + expect( + Engine.context.PredictController.trackPredictOrderEvent, + ).toHaveBeenCalledWith({ + status: 'initiated', + providerId: 'polymarket', + amountUsd: undefined, + analyticsProperties: { + entryPoint: 'homepage_balance', + transactionType: 'mm_predict_deposit', + }, + }); + }); + }); }); diff --git a/app/components/UI/Predict/hooks/usePredictDeposit.ts b/app/components/UI/Predict/hooks/usePredictDeposit.ts index d45d5538238..fabc422df1f 100644 --- a/app/components/UI/Predict/hooks/usePredictDeposit.ts +++ b/app/components/UI/Predict/hooks/usePredictDeposit.ts @@ -5,6 +5,7 @@ import { strings } from '../../../../../locales/i18n'; import { IconName } from '../../../../component-library/components/Icons/Icon'; import { ToastContext } from '../../../../component-library/components/Toast'; import { ToastVariants } from '../../../../component-library/components/Toast/Toast.types'; +import Engine from '../../../../core/Engine'; import Logger from '../../../../util/Logger'; import { useAppThemeFromContext } from '../../../../util/theme'; import { ConfirmationLoader } from '../../../Views/confirmations/components/confirm/confirm-component'; @@ -15,11 +16,21 @@ import { PredictNavigationParamList } from '../types/navigation'; import { ensureError } from '../utils/predictErrorHandler'; import { usePredictTrading } from './usePredictTrading'; import { getEvmAccountFromSelectedAccountGroup } from '../utils/accounts'; +import { + PredictEventValues, + PredictTradeStatus, +} from '../constants/eventNames'; +import { PlaceOrderParams } from '../providers/types'; interface UsePredictDepositParams { providerId?: string; } +interface PredictDepositAnalyticsParams { + amountUsd?: number; + analyticsProperties?: PlaceOrderParams['analyticsProperties']; +} + export const usePredictDeposit = ({ providerId = 'polymarket', }: UsePredictDepositParams = {}) => { @@ -41,34 +52,74 @@ export const usePredictDeposit = ({ }), ); - const deposit = useCallback(async () => { - try { - navigateToConfirmation({ - loader: ConfirmationLoader.CustomAmount, - }); + const deposit = useCallback( + async (params?: PredictDepositAnalyticsParams) => { + try { + navigateToConfirmation({ + loader: ConfirmationLoader.CustomAmount, + }); - depositWithConfirmation({ - providerId, - }).catch((err) => { - console.error('Failed to initialize deposit:', err); + const { amountUsd, analyticsProperties } = params ?? {}; - // Log error with deposit initialization context - Logger.error(ensureError(err), { - tags: { - feature: PREDICT_CONSTANTS.FEATURE_NAME, - component: 'usePredictDeposit', - }, - context: { - name: 'usePredictDeposit', - data: { - method: 'deposit', - action: 'deposit_initialization', - operation: 'financial_operations', - providerId, + if (analyticsProperties) { + Engine.context.PredictController.trackPredictOrderEvent({ + status: PredictTradeStatus.INITIATED, + providerId, + amountUsd, + analyticsProperties: { + ...analyticsProperties, + transactionType: + PredictEventValues.TRANSACTION_TYPE.MM_PREDICT_DEPOSIT, }, - }, + }); + } + + depositWithConfirmation({ + providerId, + }).catch((err) => { + console.error('Failed to initialize deposit:', err); + + // Log error with deposit initialization context + Logger.error(ensureError(err), { + tags: { + feature: PREDICT_CONSTANTS.FEATURE_NAME, + component: 'usePredictDeposit', + }, + context: { + name: 'usePredictDeposit', + data: { + method: 'deposit', + action: 'deposit_initialization', + operation: 'financial_operations', + providerId, + }, + }, + }); + navigation.goBack(); + toastRef?.current?.showToast({ + variant: ToastVariants.Icon, + labelOptions: [ + { label: strings('predict.deposit.error_title'), isBold: true }, + { label: '\n', isBold: false }, + { + label: strings('predict.deposit.error_description'), + isBold: false, + }, + ], + iconName: IconName.Error, + iconColor: theme.colors.error.default, + backgroundColor: theme.colors.accent04.normal, + hasNoTimeout: false, + linkButtonOptions: { + label: strings('predict.deposit.try_again'), + onPress: () => deposit(params), + }, + }); }); + } catch (err) { + console.error('Failed to proceed with deposit:', err); navigation.goBack(); + // Re-throw to allow testing of this error path toastRef?.current?.showToast({ variant: ToastVariants.Icon, labelOptions: [ @@ -85,60 +136,38 @@ export const usePredictDeposit = ({ hasNoTimeout: false, linkButtonOptions: { label: strings('predict.deposit.try_again'), - onPress: () => deposit(), + onPress: () => deposit(params), }, }); - }); - } catch (err) { - console.error('Failed to proceed with deposit:', err); - navigation.goBack(); - // Re-throw to allow testing of this error path - toastRef?.current?.showToast({ - variant: ToastVariants.Icon, - labelOptions: [ - { label: strings('predict.deposit.error_title'), isBold: true }, - { label: '\n', isBold: false }, - { - label: strings('predict.deposit.error_description'), - isBold: false, - }, - ], - iconName: IconName.Error, - iconColor: theme.colors.error.default, - backgroundColor: theme.colors.accent04.normal, - hasNoTimeout: false, - linkButtonOptions: { - label: strings('predict.deposit.try_again'), - onPress: () => deposit(), - }, - }); - // Log error with deposit navigation context - Logger.error(ensureError(err), { - tags: { - feature: PREDICT_CONSTANTS.FEATURE_NAME, - component: 'usePredictDeposit', - }, - context: { - name: 'usePredictDeposit', - data: { - method: 'deposit', - action: 'deposit_navigation', - operation: 'financial_operations', - providerId, + // Log error with deposit navigation context + Logger.error(ensureError(err), { + tags: { + feature: PREDICT_CONSTANTS.FEATURE_NAME, + component: 'usePredictDeposit', }, - }, - }); - } - }, [ - depositWithConfirmation, - navigateToConfirmation, - navigation, - providerId, - theme.colors.accent04.normal, - theme.colors.error.default, - toastRef, - ]); + context: { + name: 'usePredictDeposit', + data: { + method: 'deposit', + action: 'deposit_navigation', + operation: 'financial_operations', + providerId, + }, + }, + }); + } + }, + [ + depositWithConfirmation, + navigateToConfirmation, + navigation, + providerId, + theme.colors.accent04.normal, + theme.colors.error.default, + toastRef, + ], + ); return { deposit, diff --git a/app/components/UI/Predict/hooks/usePredictPlaceOrder.test.ts b/app/components/UI/Predict/hooks/usePredictPlaceOrder.test.ts index 21f4bf61b64..694e8389097 100644 --- a/app/components/UI/Predict/hooks/usePredictPlaceOrder.test.ts +++ b/app/components/UI/Predict/hooks/usePredictPlaceOrder.test.ts @@ -498,7 +498,7 @@ describe('usePredictPlaceOrder', () => { const ZERO_BALANCE = 0; const EXACT_BALANCE_MATCH = 100; - it('triggers deposit when balance is insufficient for BUY order', async () => { + it('triggers deposit with analytics properties when balance is insufficient for BUY order', async () => { mockUsePredictBalance.mockReturnValue({ balance: INSUFFICIENT_BALANCE, hasNoBalance: false, @@ -515,10 +515,53 @@ describe('usePredictPlaceOrder', () => { }); expect(mockDeposit).toHaveBeenCalledTimes(1); + expect(mockDeposit).toHaveBeenCalledWith({ + amountUsd: mockOrderParams.preview.maxAmountSpent, + analyticsProperties: { + marketId: mockOrderParams.preview.marketId, + entryPoint: 'buy_preview', + }, + }); expect(mockPlaceOrder).not.toHaveBeenCalled(); expect(mockToastRef.current?.showToast).not.toHaveBeenCalled(); }); + it('triggers deposit with merged analytics properties when orderParams has analyticsProperties', async () => { + mockUsePredictBalance.mockReturnValue({ + balance: INSUFFICIENT_BALANCE, + hasNoBalance: false, + isLoading: false, + isRefreshing: false, + error: null, + loadBalance: jest.fn(), + }); + + const orderParamsWithAnalytics = { + ...mockOrderParams, + analyticsProperties: { + marketTitle: 'Test Market', + marketCategory: 'sports', + }, + }; + + const { result } = renderHook(() => usePredictPlaceOrder()); + + await act(async () => { + await result.current.placeOrder(orderParamsWithAnalytics); + }); + + expect(mockDeposit).toHaveBeenCalledTimes(1); + expect(mockDeposit).toHaveBeenCalledWith({ + amountUsd: mockOrderParams.preview.maxAmountSpent, + analyticsProperties: { + marketTitle: 'Test Market', + marketCategory: 'sports', + marketId: mockOrderParams.preview.marketId, + entryPoint: 'buy_preview', + }, + }); + }); + it('does not trigger deposit when balance is sufficient for BUY order', async () => { mockPlaceOrder.mockResolvedValue(mockSuccessResult); mockUsePredictBalance.mockReturnValue({ diff --git a/app/components/UI/Predict/hooks/usePredictPlaceOrder.ts b/app/components/UI/Predict/hooks/usePredictPlaceOrder.ts index 68f496615e5..1af11323015 100644 --- a/app/components/UI/Predict/hooks/usePredictPlaceOrder.ts +++ b/app/components/UI/Predict/hooks/usePredictPlaceOrder.ts @@ -21,6 +21,7 @@ import { ensureError, parseErrorMessage } from '../utils/predictErrorHandler'; import { PREDICT_CONSTANTS, PREDICT_ERROR_CODES } from '../constants/errors'; import { usePredictBalance } from './usePredictBalance'; import { usePredictDeposit } from './usePredictDeposit'; +import { PredictEventValues } from '../constants/eventNames'; interface UsePredictPlaceOrderOptions { /** @@ -135,7 +136,14 @@ export function usePredictPlaceOrder( // Check if user has sufficient balance for the bet amount // maxAmountSpent includes the bet amount plus all fees if (side === Side.BUY && balance < maxAmountSpent) { - await deposit(); + await deposit({ + amountUsd: maxAmountSpent, + analyticsProperties: { + ...orderParams.analyticsProperties, + marketId: orderParams.preview.marketId, + entryPoint: PredictEventValues.ENTRY_POINT.BUY_PREVIEW, + }, + }); return; } diff --git a/app/components/UI/Predict/mocks/remoteFeatureFlagMocks.ts b/app/components/UI/Predict/mocks/remoteFeatureFlagMocks.ts index ef232182d82..39dee3cedd5 100644 --- a/app/components/UI/Predict/mocks/remoteFeatureFlagMocks.ts +++ b/app/components/UI/Predict/mocks/remoteFeatureFlagMocks.ts @@ -15,6 +15,7 @@ export const mockedPredictFeatureFlagsEnabledState: Record< export const mockPredictMarketHighlightsFlag: PredictMarketHighlightsFlag = { enabled: true, + minimumVersion: '0.0.0', highlights: [ { category: 'trending', diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts index 9e4120b31a9..228b36941c9 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts @@ -7,7 +7,7 @@ import { Hex, numberToHex } from '@metamask/utils'; import { parseUnits } from 'ethers/lib/utils'; import { DevLogger } from '../../../../../core/SDKConnect/utils/DevLogger'; import Logger, { type LoggerErrorOptions } from '../../../../../util/Logger'; -import { MetaMetrics } from '../../../../../core/Analytics'; +import { analytics } from '../../../../../util/analytics/analytics'; import { UserProfileProperty } from '../../../../../util/metrics/UserSettingsAnalyticsMetaData/UserProfileAnalyticsMetaData.types'; import { generateTransferData, @@ -1403,28 +1403,11 @@ export class PolymarketProvider implements PredictProvider { /** * Set user trait for Polymarket account creation via MetaMask - * Fire-and-forget operation that logs errors but doesn't fail */ private setPolymarketAccountCreatedTrait(): void { - MetaMetrics.getInstance() - .addTraitsToUser({ - [UserProfileProperty.CREATED_POLYMARKET_ACCOUNT_VIA_MM]: true, - }) - .catch((error) => { - // Log error but don't fail the deposit preparation - Logger.error(error as Error, { - tags: { - feature: PREDICT_CONSTANTS.FEATURE_NAME, - provider: 'polymarket', - }, - context: { - name: 'PolymarketProvider', - data: { - method: 'setPolymarketAccountCreatedTrait', - }, - }, - }); - }); + analytics.identify({ + [UserProfileProperty.CREATED_POLYMARKET_ACCOUNT_VIA_MM]: true, + }); } public async prepareDeposit( diff --git a/app/components/UI/Predict/types/flags.ts b/app/components/UI/Predict/types/flags.ts index 717b805856d..9444940b7c5 100644 --- a/app/components/UI/Predict/types/flags.ts +++ b/app/components/UI/Predict/types/flags.ts @@ -19,8 +19,7 @@ export interface PredictMarketHighlight { markets: string[]; } -export interface PredictMarketHighlightsFlag { - enabled: boolean; +export interface PredictMarketHighlightsFlag extends VersionGatedFeatureFlag { highlights: PredictMarketHighlight[]; } diff --git a/app/components/UI/Predict/views/PredictFeed/PredictFeed.tsx b/app/components/UI/Predict/views/PredictFeed/PredictFeed.tsx index 9f735079ad9..67548b8d411 100644 --- a/app/components/UI/Predict/views/PredictFeed/PredictFeed.tsx +++ b/app/components/UI/Predict/views/PredictFeed/PredictFeed.tsx @@ -73,7 +73,7 @@ import { TabItem, TabsBar, } from '../../../../../component-library/components-temp/Tabs'; -import HeaderCenter from '../../../../../component-library/components-temp/HeaderCenter'; +import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; import { selectPredictHotTabFlag } from '../../selectors/featureFlags'; interface FeedTab { @@ -706,7 +706,7 @@ const PredictFeed: React.FC = () => { paddingTop: insets.top, })} > - { if (!order) { return ( - navigation.goBack()} @@ -226,7 +226,7 @@ const OrderDetails = () => { if (isLoading) { return ( - navigation.goBack()} @@ -243,7 +243,7 @@ const OrderDetails = () => { if (error) { return ( - navigation.goBack()} @@ -261,7 +261,7 @@ const OrderDetails = () => { return ( - navigation.goBack()} diff --git a/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.tsx b/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.tsx index fa6607e03ab..989ee685d70 100644 --- a/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.tsx @@ -37,7 +37,7 @@ import { import BottomSheet, { BottomSheetRef, } from '../../../../../../component-library/components/BottomSheets/BottomSheet'; -import HeaderCenter from '../../../../../../component-library/components-temp/HeaderCenter'; +import HeaderCompactStandard from '../../../../../../component-library/components-temp/HeaderCompactStandard'; import BottomSheetFooter, { ButtonsAlignment, } from '../../../../../../component-library/components/BottomSheets/BottomSheetFooter'; @@ -825,7 +825,9 @@ function Quotes() { return ( - handleClosePress(bottomSheetRef)} /> + handleClosePress(bottomSheetRef)} + /> ); @@ -847,7 +849,9 @@ function Quotes() { return ( - handleClosePress(bottomSheetRef)} /> + handleClosePress(bottomSheetRef)} + /> - handleClosePress(bottomSheetRef)} /> + handleClosePress(bottomSheetRef)} + /> - handleClosePress(bottomSheetRef)} /> + handleClosePress(bottomSheetRef)} + /> - handleClosePress(bottomSheetRef)} /> + handleClosePress(bottomSheetRef)} + /> - handleClosePress(bottomSheetRef)} /> @@ -1070,7 +1080,7 @@ function Quotes() { return ( - handleClosePress(bottomSheetRef)} /> diff --git a/app/components/UI/Ramp/Aggregator/Views/Settings/__snapshots__/ActivationKeyForm.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/Settings/__snapshots__/ActivationKeyForm.test.tsx.snap index 8f07088f595..385f6190bbd 100644 --- a/app/components/UI/Ramp/Aggregator/Views/Settings/__snapshots__/ActivationKeyForm.test.tsx.snap +++ b/app/components/UI/Ramp/Aggregator/Views/Settings/__snapshots__/ActivationKeyForm.test.tsx.snap @@ -545,12 +545,12 @@ exports[`AddActivationKey renders correctly 1`] = ` style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#4459ff", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc8", + "borderRadius": 12, "borderWidth": 1, "flexDirection": "row", - "height": 40, + "height": 48, "opacity": 1, "paddingHorizontal": 16, } @@ -568,6 +568,7 @@ exports[`AddActivationKey renders correctly 1`] = ` autoCapitalize="none" autoFocus={true} editable={true} + multiline={false} numberOfLines={1} onBlur={[Function]} onChangeText={[Function]} @@ -584,7 +585,7 @@ exports[`AddActivationKey renders correctly 1`] = ` "fontFamily": "Geist-Regular", "fontSize": 16, "fontWeight": "400", - "height": 38, + "height": 46, "letterSpacing": 0, "opacity": 1, "paddingVertical": 2, @@ -656,12 +657,12 @@ exports[`AddActivationKey renders correctly 1`] = ` style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#4459ff", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc8", + "borderRadius": 12, "borderWidth": 1, "flexDirection": "row", - "height": 40, + "height": 48, "opacity": 1, "paddingHorizontal": 16, } @@ -680,6 +681,7 @@ exports[`AddActivationKey renders correctly 1`] = ` autoCorrect={false} autoFocus={true} editable={true} + multiline={false} numberOfLines={1} onBlur={[Function]} onChangeText={[Function]} @@ -697,7 +699,7 @@ exports[`AddActivationKey renders correctly 1`] = ` "fontFamily": "Geist-Regular", "fontSize": 16, "fontWeight": "400", - "height": 38, + "height": 46, "letterSpacing": 0, "opacity": 1, "paddingVertical": 2, diff --git a/app/components/UI/Ramp/Aggregator/components/FiatSelectorModal/FiatSelectorModal.tsx b/app/components/UI/Ramp/Aggregator/components/FiatSelectorModal/FiatSelectorModal.tsx index 4c01e966565..23bc02fce4a 100644 --- a/app/components/UI/Ramp/Aggregator/components/FiatSelectorModal/FiatSelectorModal.tsx +++ b/app/components/UI/Ramp/Aggregator/components/FiatSelectorModal/FiatSelectorModal.tsx @@ -13,7 +13,7 @@ import { useRampSDK } from '../../sdk'; import BottomSheet, { BottomSheetRef, } from '../../../../../../component-library/components/BottomSheets/BottomSheet'; -import HeaderCenter from '../../../../../../component-library/components-temp/HeaderCenter'; +import HeaderCompactStandard from '../../../../../../component-library/components-temp/HeaderCompactStandard'; import TextFieldSearch from '../../../../../../component-library/components/Form/TextFieldSearch'; import ListItemSelect from '../../../../../../component-library/components/List/ListItemSelect'; import ListItemColumn, { @@ -135,7 +135,7 @@ function FiatSelectorModal() { return ( - sheetRef.current?.onCloseBottomSheet()} /> diff --git a/app/components/UI/Ramp/Aggregator/components/FiatSelectorModal/__snapshots__/FiatSelectorModal.test.tsx.snap b/app/components/UI/Ramp/Aggregator/components/FiatSelectorModal/__snapshots__/FiatSelectorModal.test.tsx.snap index 5a7389502ae..9804b274473 100644 --- a/app/components/UI/Ramp/Aggregator/components/FiatSelectorModal/__snapshots__/FiatSelectorModal.test.tsx.snap +++ b/app/components/UI/Ramp/Aggregator/components/FiatSelectorModal/__snapshots__/FiatSelectorModal.test.tsx.snap @@ -623,12 +623,12 @@ exports[`FiatSelectorModal renders the modal with currency list 1`] = ` style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc866", + "borderRadius": 24, "borderWidth": 1, "flexDirection": "row", - "height": 40, + "height": 48, "opacity": 1, "paddingHorizontal": 16, } @@ -638,23 +638,23 @@ exports[`FiatSelectorModal renders the modal with currency list 1`] = ` }, }); -const renderDescription = (description: TimeDescriptions | string) => { - switch (description) { - case TimeDescriptions.instant: { - return strings('fiat_on_ramp_aggregator.payment_method.instant'); - } - case TimeDescriptions.less_than: { - return strings('fiat_on_ramp_aggregator.payment_method.less_than'); - } - case TimeDescriptions.separator: { - return '-'; - } - case TimeDescriptions.minutes: { - return strings('fiat_on_ramp_aggregator.payment_method.minutes'); - } - case TimeDescriptions.minute: { - return strings('fiat_on_ramp_aggregator.payment_method.minute'); - } - case TimeDescriptions.hours: { - return strings('fiat_on_ramp_aggregator.payment_method.hours'); - } - case TimeDescriptions.hour: { - return strings('fiat_on_ramp_aggregator.payment_method.hour'); - } - case TimeDescriptions.business_days: { - return strings('fiat_on_ramp_aggregator.payment_method.business_days'); - } - case TimeDescriptions.business_day: { - return strings('fiat_on_ramp_aggregator.payment_method.business_day'); - } - default: { - return description; - } - } -}; -const renderTime = (time: number[]) => - timeToDescription(time).map(renderDescription).join(' '); - const tierDescriptions = [ strings('fiat_on_ramp_aggregator.payment_method.lowest_limit'), strings('fiat_on_ramp_aggregator.payment_method.medium_limit'), @@ -140,7 +103,7 @@ const PaymentMethod: React.FC = ({ - {renderTime(time)} •{' '} + {formatDelayFromArray(time)} •{' '} {new Array(amountTier[1]).fill('').map((_, index) => ( - sheetRef.current?.onCloseBottomSheet()} /> diff --git a/app/components/UI/Ramp/Aggregator/components/RegionSelectorModal/RegionSelectorModal.tsx b/app/components/UI/Ramp/Aggregator/components/RegionSelectorModal/RegionSelectorModal.tsx index e5496b549d6..75fe6eaa015 100644 --- a/app/components/UI/Ramp/Aggregator/components/RegionSelectorModal/RegionSelectorModal.tsx +++ b/app/components/UI/Ramp/Aggregator/components/RegionSelectorModal/RegionSelectorModal.tsx @@ -23,7 +23,7 @@ import { AnimationDuration } from '../../../../../../component-library/constants import BottomSheet, { BottomSheetRef, } from '../../../../../../component-library/components/BottomSheets/BottomSheet'; -import HeaderCenter from '../../../../../../component-library/components-temp/HeaderCenter'; +import HeaderCompactStandard from '../../../../../../component-library/components-temp/HeaderCompactStandard'; import ListItemSelect from '../../../../../../component-library/components/List/ListItemSelect'; import ListItemColumn, { WidthType, @@ -333,7 +333,7 @@ function RegionSelectorModal() { onClose={onModalHide} keyboardAvoidingViewEnabled={false} > - - sheetRef.current?.onCloseBottomSheet()} /> diff --git a/app/components/UI/Ramp/Aggregator/components/TokenSelectModal/__snapshots__/TokenSelectModal.test.tsx.snap b/app/components/UI/Ramp/Aggregator/components/TokenSelectModal/__snapshots__/TokenSelectModal.test.tsx.snap index b8db85819ea..4e1226fb11f 100644 --- a/app/components/UI/Ramp/Aggregator/components/TokenSelectModal/__snapshots__/TokenSelectModal.test.tsx.snap +++ b/app/components/UI/Ramp/Aggregator/components/TokenSelectModal/__snapshots__/TokenSelectModal.test.tsx.snap @@ -809,12 +809,12 @@ exports[`TokenSelectModal renders the modal with token list 1`] = ` style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc866", + "borderRadius": 24, "borderWidth": 1, "flexDirection": "row", - "height": 40, + "height": 48, "opacity": 1, "paddingHorizontal": 16, } @@ -824,23 +824,23 @@ exports[`TokenSelectModal renders the modal with token list 1`] = ` { ]; }; +export const renderDelayToken = (token: TimeDescriptions | string): string => { + switch (token) { + case TimeDescriptions.instant: + return strings('fiat_on_ramp_aggregator.payment_method.instant'); + case TimeDescriptions.less_than: + return strings('fiat_on_ramp_aggregator.payment_method.less_than'); + case TimeDescriptions.separator: + return '-'; + case TimeDescriptions.minutes: + return strings('fiat_on_ramp_aggregator.payment_method.minutes'); + case TimeDescriptions.minute: + return strings('fiat_on_ramp_aggregator.payment_method.minute'); + case TimeDescriptions.hours: + return strings('fiat_on_ramp_aggregator.payment_method.hours'); + case TimeDescriptions.hour: + return strings('fiat_on_ramp_aggregator.payment_method.hour'); + case TimeDescriptions.business_days: + return strings('fiat_on_ramp_aggregator.payment_method.business_days'); + case TimeDescriptions.business_day: + return strings('fiat_on_ramp_aggregator.payment_method.business_day'); + default: + return String(token); + } +}; + +export const formatDelayFromArray = (delay: number[]): string => + timeToDescription(delay).map(renderDelayToken).join(' '); + export const formatId = (id: string) => { if (!id) { return id; diff --git a/app/components/UI/Ramp/Deposit/Views/BasicInfo/__snapshots__/BasicInfo.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/BasicInfo/__snapshots__/BasicInfo.test.tsx.snap index 536d07be704..46481ce3343 100644 --- a/app/components/UI/Ramp/Deposit/Views/BasicInfo/__snapshots__/BasicInfo.test.tsx.snap +++ b/app/components/UI/Ramp/Deposit/Views/BasicInfo/__snapshots__/BasicInfo.test.tsx.snap @@ -645,9 +645,9 @@ exports[`BasicInfo Component navigates to address page when form is valid and co style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc866", + "borderRadius": 12, "borderWidth": 1, "flexDirection": "row", "height": 48, @@ -670,6 +670,8 @@ exports[`BasicInfo Component navigates to address page when form is valid and co autoFocus={false} editable={true} keyboardAppearance="light" + multiline={false} + numberOfLines={1} onBlur={[Function]} onChangeText={[Function]} onFocus={[Function]} @@ -762,9 +764,9 @@ exports[`BasicInfo Component navigates to address page when form is valid and co style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc866", + "borderRadius": 12, "borderWidth": 1, "flexDirection": "row", "height": 48, @@ -787,6 +789,8 @@ exports[`BasicInfo Component navigates to address page when form is valid and co autoFocus={false} editable={true} keyboardAppearance="light" + multiline={false} + numberOfLines={1} onBlur={[Function]} onChangeText={[Function]} onFocus={[Function]} @@ -878,9 +882,9 @@ exports[`BasicInfo Component navigates to address page when form is valid and co style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc866", + "borderRadius": 12, "borderWidth": 1, "flexDirection": "row", "height": 48, @@ -893,7 +897,7 @@ exports[`BasicInfo Component navigates to address page when form is valid and co { - sheetRef.current?.onCloseBottomSheet()} /> diff --git a/app/components/UI/Ramp/Deposit/Views/Modals/RegionSelectorModal/RegionSelectorModal.tsx b/app/components/UI/Ramp/Deposit/Views/Modals/RegionSelectorModal/RegionSelectorModal.tsx index 68ae54343c5..aea7625b900 100644 --- a/app/components/UI/Ramp/Deposit/Views/Modals/RegionSelectorModal/RegionSelectorModal.tsx +++ b/app/components/UI/Ramp/Deposit/Views/Modals/RegionSelectorModal/RegionSelectorModal.tsx @@ -10,7 +10,7 @@ import Text, { import BottomSheet, { BottomSheetRef, } from '../../../../../../../component-library/components/BottomSheets/BottomSheet'; -import HeaderCenter from '../../../../../../../component-library/components-temp/HeaderCenter'; +import HeaderCompactStandard from '../../../../../../../component-library/components-temp/HeaderCompactStandard'; import ListItemSelect from '../../../../../../../component-library/components/List/ListItemSelect'; import ListItemColumn, { WidthType, @@ -226,7 +226,7 @@ function RegionSelectorModal() { return ( - sheetRef.current?.onCloseBottomSheet()} /> diff --git a/app/components/UI/Ramp/Deposit/Views/Modals/RegionSelectorModal/__snapshots__/RegionSelectorModal.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/Modals/RegionSelectorModal/__snapshots__/RegionSelectorModal.test.tsx.snap index 2f4b32a9ecc..02272302892 100644 --- a/app/components/UI/Ramp/Deposit/Views/Modals/RegionSelectorModal/__snapshots__/RegionSelectorModal.test.tsx.snap +++ b/app/components/UI/Ramp/Deposit/Views/Modals/RegionSelectorModal/__snapshots__/RegionSelectorModal.test.tsx.snap @@ -623,12 +623,12 @@ exports[`RegionSelectorModal Component handles empty regions array from navigati style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc866", + "borderRadius": 24, "borderWidth": 1, "flexDirection": "row", - "height": 40, + "height": 48, "opacity": 1, "paddingHorizontal": 16, } @@ -638,23 +638,23 @@ exports[`RegionSelectorModal Component handles empty regions array from navigati @@ -4356,12 +4364,12 @@ exports[`RegionSelectorModal Component render matches snapshot when searching fo style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc866", + "borderRadius": 24, "borderWidth": 1, "flexDirection": "row", - "height": 40, + "height": 48, "opacity": 1, "paddingHorizontal": 16, } @@ -4371,23 +4379,23 @@ exports[`RegionSelectorModal Component render matches snapshot when searching fo @@ -5252,12 +5262,12 @@ exports[`RegionSelectorModal Component render matches snapshot with allRegionsSe style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc866", + "borderRadius": 24, "borderWidth": 1, "flexDirection": "row", - "height": 40, + "height": 48, "opacity": 1, "paddingHorizontal": 16, } @@ -5267,23 +5277,23 @@ exports[`RegionSelectorModal Component render matches snapshot with allRegionsSe - sheetRef.current?.onCloseBottomSheet()} /> diff --git a/app/components/UI/Ramp/Deposit/Views/Modals/StateSelectorModal/StateSelectorModal.tsx b/app/components/UI/Ramp/Deposit/Views/Modals/StateSelectorModal/StateSelectorModal.tsx index 57c823ffd3f..e4dc9ceab06 100644 --- a/app/components/UI/Ramp/Deposit/Views/Modals/StateSelectorModal/StateSelectorModal.tsx +++ b/app/components/UI/Ramp/Deposit/Views/Modals/StateSelectorModal/StateSelectorModal.tsx @@ -11,7 +11,7 @@ import Text, { import BottomSheet, { BottomSheetRef, } from '../../../../../../../component-library/components/BottomSheets/BottomSheet'; -import HeaderCenter from '../../../../../../../component-library/components-temp/HeaderCenter'; +import HeaderCompactStandard from '../../../../../../../component-library/components-temp/HeaderCompactStandard'; import ListItemSelect from '../../../../../../../component-library/components/List/ListItemSelect'; import ListItemColumn, { WidthType, @@ -156,7 +156,7 @@ function StateSelectorModal() { return ( - sheetRef.current?.onCloseBottomSheet()} closeButtonProps={{ testID: 'state-selector-close-button' }} diff --git a/app/components/UI/Ramp/Deposit/Views/Modals/StateSelectorModal/__snapshots__/StateSelectorModal.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/Modals/StateSelectorModal/__snapshots__/StateSelectorModal.test.tsx.snap index 0204e419fd6..220a1193006 100644 --- a/app/components/UI/Ramp/Deposit/Views/Modals/StateSelectorModal/__snapshots__/StateSelectorModal.test.tsx.snap +++ b/app/components/UI/Ramp/Deposit/Views/Modals/StateSelectorModal/__snapshots__/StateSelectorModal.test.tsx.snap @@ -623,12 +623,12 @@ exports[`StateSelectorModal Component Snapshot Tests renders cleared search stat style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc866", + "borderRadius": 24, "borderWidth": 1, "flexDirection": "row", - "height": 40, + "height": 48, "opacity": 1, "paddingHorizontal": 16, } @@ -638,23 +638,23 @@ exports[`StateSelectorModal Component Snapshot Tests renders cleared search stat @@ -1486,12 +1488,12 @@ exports[`StateSelectorModal Component Snapshot Tests renders empty state when no style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc866", + "borderRadius": 24, "borderWidth": 1, "flexDirection": "row", - "height": 40, + "height": 48, "opacity": 1, "paddingHorizontal": 16, } @@ -1501,23 +1503,23 @@ exports[`StateSelectorModal Component Snapshot Tests renders empty state when no @@ -2293,12 +2297,12 @@ exports[`StateSelectorModal Component Snapshot Tests renders filtered state when style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc866", + "borderRadius": 24, "borderWidth": 1, "flexDirection": "row", - "height": 40, + "height": 48, "opacity": 1, "paddingHorizontal": 16, } @@ -2308,23 +2312,23 @@ exports[`StateSelectorModal Component Snapshot Tests renders filtered state when @@ -3156,12 +3162,12 @@ exports[`StateSelectorModal Component Snapshot Tests renders filtered state when style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc866", + "borderRadius": 24, "borderWidth": 1, "flexDirection": "row", - "height": 40, + "height": 48, "opacity": 1, "paddingHorizontal": 16, } @@ -3171,23 +3177,23 @@ exports[`StateSelectorModal Component Snapshot Tests renders filtered state when @@ -4019,12 +4027,12 @@ exports[`StateSelectorModal Component Snapshot Tests renders initial state corre style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc866", + "borderRadius": 24, "borderWidth": 1, "flexDirection": "row", - "height": 40, + "height": 48, "opacity": 1, "paddingHorizontal": 16, } @@ -4034,23 +4042,23 @@ exports[`StateSelectorModal Component Snapshot Tests renders initial state corre diff --git a/app/components/UI/Ramp/Deposit/Views/Modals/TokenSelectorModal/TokenSelectorModal.tsx b/app/components/UI/Ramp/Deposit/Views/Modals/TokenSelectorModal/TokenSelectorModal.tsx index c1309578591..9229dec6512 100644 --- a/app/components/UI/Ramp/Deposit/Views/Modals/TokenSelectorModal/TokenSelectorModal.tsx +++ b/app/components/UI/Ramp/Deposit/Views/Modals/TokenSelectorModal/TokenSelectorModal.tsx @@ -14,7 +14,7 @@ import Text, { import BottomSheet, { BottomSheetRef, } from '../../../../../../../component-library/components/BottomSheets/BottomSheet'; -import HeaderCenter from '../../../../../../../component-library/components-temp/HeaderCenter'; +import HeaderCompactStandard from '../../../../../../../component-library/components-temp/HeaderCompactStandard'; import ListItemSelect from '../../../../../../../component-library/components/List/ListItemSelect'; import TextFieldSearch from '../../../../../../../component-library/components/Form/TextFieldSearch'; @@ -173,7 +173,7 @@ function TokenSelectorModal() { return ( - @@ -3042,12 +3044,12 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc866", + "borderRadius": 24, "borderWidth": 1, "flexDirection": "row", - "height": 40, + "height": 48, "opacity": 1, "paddingHorizontal": 16, } @@ -3057,23 +3059,23 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] - sheetRef.current?.onCloseBottomSheet()} /> + sheetRef.current?.onCloseBottomSheet()} + /> diff --git a/app/components/UI/Ramp/Deposit/components/DepositDateField/__snapshots__/DepositDateField.test.tsx.snap b/app/components/UI/Ramp/Deposit/components/DepositDateField/__snapshots__/DepositDateField.test.tsx.snap index 63c1d5c1800..06fecba9282 100644 --- a/app/components/UI/Ramp/Deposit/components/DepositDateField/__snapshots__/DepositDateField.test.tsx.snap +++ b/app/components/UI/Ramp/Deposit/components/DepositDateField/__snapshots__/DepositDateField.test.tsx.snap @@ -61,9 +61,9 @@ exports[`DepositDateField Platform specific rendering date picker matches snapsh style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc866", + "borderRadius": 12, "borderWidth": 1, "flexDirection": "row", "height": 48, @@ -76,7 +76,7 @@ exports[`DepositDateField Platform specific rendering date picker matches snapsh { const wrapper = shallow(); const textFieldComponent = wrapper.find(TextField); expect(textFieldComponent.exists()).toBe(true); - expect(textFieldComponent.prop('size')).toBe(TextFieldSize.Lg); expect(textFieldComponent.prop('placeholderTextColor')).toBe( mockTheme.colors.text.muted, ); diff --git a/app/components/UI/Ramp/Deposit/components/DepositTextField/DepositTextField.tsx b/app/components/UI/Ramp/Deposit/components/DepositTextField/DepositTextField.tsx index 49751078fed..58b579b916e 100644 --- a/app/components/UI/Ramp/Deposit/components/DepositTextField/DepositTextField.tsx +++ b/app/components/UI/Ramp/Deposit/components/DepositTextField/DepositTextField.tsx @@ -11,13 +11,11 @@ import Label from '../../../../../../component-library/components/Form/Label'; import Text, { TextVariant, } from '../../../../../../component-library/components/Texts/Text'; -import TextField, { - TextFieldSize, -} from '../../../../../../component-library/components/Form/TextField'; +import TextField from '../../../../../../component-library/components/Form/TextField'; import { TextFieldProps } from '../../../../../../component-library/components/Form/TextField/TextField.types'; import { Theme } from '../../../../../../util/theme/models'; -interface DepositTextFieldProps extends Omit { +interface DepositTextFieldProps extends TextFieldProps { label: string | React.ReactNode; error?: string; containerStyle?: StyleProp; @@ -56,7 +54,6 @@ const DepositTextField = forwardRef( {label} )} diff --git a/app/components/UI/Ramp/Deposit/testUtils/constants.ts b/app/components/UI/Ramp/Deposit/testUtils/constants.ts index d58eb99fffa..410f56757e4 100644 --- a/app/components/UI/Ramp/Deposit/testUtils/constants.ts +++ b/app/components/UI/Ramp/Deposit/testUtils/constants.ts @@ -294,6 +294,7 @@ export const MOCK_ANALYTICS_DEPOSIT_ORDER = { state: FIAT_ORDER_STATES.COMPLETED, network: 'eip155:1', data: { + provider: 'TRANSAK', cryptoCurrency: MOCK_USDC_TOKEN, network: { chainId: 'eip155:1', name: 'Ethereum' }, fiatAmount: '100', diff --git a/app/components/UI/Ramp/Deposit/types/analytics.ts b/app/components/UI/Ramp/Deposit/types/analytics.ts index 6b62f331f6f..a695e8e4fac 100644 --- a/app/components/UI/Ramp/Deposit/types/analytics.ts +++ b/app/components/UI/Ramp/Deposit/types/analytics.ts @@ -194,6 +194,7 @@ interface RampsTransactionCompleted { currency_destination_symbol?: string; currency_destination_network?: string; currency_source: string; + provider_onramp: string; } interface RampsTransactionFailed { @@ -214,6 +215,7 @@ interface RampsTransactionFailed { currency_destination_network?: string; currency_source: string; error_message: string; + provider_onramp: string; } interface RampsKycApplicationFailed { diff --git a/app/components/UI/Ramp/Deposit/utils/getDepositAnalyticsPayload.test.ts b/app/components/UI/Ramp/Deposit/utils/getDepositAnalyticsPayload.test.ts index 58da5a67503..98ec860acf1 100644 --- a/app/components/UI/Ramp/Deposit/utils/getDepositAnalyticsPayload.test.ts +++ b/app/components/UI/Ramp/Deposit/utils/getDepositAnalyticsPayload.test.ts @@ -43,6 +43,7 @@ describe('getDepositAnalyticsPayload', () => { currency_destination_symbol: 'USDC', currency_destination_network: 'Ethereum', currency_source: 'USD', + provider_onramp: 'TRANSAK', }); }); @@ -78,6 +79,7 @@ describe('getDepositAnalyticsPayload', () => { currency_destination_network: 'Ethereum', currency_source: 'USD', error_message: 'Payment failed', + provider_onramp: 'TRANSAK', }); }); @@ -109,6 +111,7 @@ describe('getDepositAnalyticsPayload', () => { currency_destination_network: 'Ethereum', currency_source: 'USD', error_message: 'transaction_failed', + provider_onramp: 'TRANSAK', }); }); @@ -147,6 +150,7 @@ describe('getDepositAnalyticsPayload', () => { currency_destination_symbol: 'USDC', currency_destination_network: 'Ethereum', currency_source: 'USD', + provider_onramp: 'TRANSAK', }); }); @@ -243,6 +247,7 @@ describe('getDepositAnalyticsPayload', () => { currency_destination_symbol: 'USDC', currency_destination_network: 'Ethereum', currency_source: 'USD', + provider_onramp: 'TRANSAK', }); }); @@ -276,6 +281,7 @@ describe('getDepositAnalyticsPayload', () => { currency_destination_symbol: 'USDC', currency_destination_network: 'Ethereum', currency_source: 'USD', + provider_onramp: 'TRANSAK', }); }); @@ -309,6 +315,7 @@ describe('getDepositAnalyticsPayload', () => { currency_destination_network: 'Ethereum', currency_destination_symbol: 'USDC', currency_source: 'USD', + provider_onramp: 'TRANSAK', }); }); }); diff --git a/app/components/UI/Ramp/Deposit/utils/getDepositAnalyticsPayload.ts b/app/components/UI/Ramp/Deposit/utils/getDepositAnalyticsPayload.ts index 2e5c54ff5f9..b340b9bddcd 100644 --- a/app/components/UI/Ramp/Deposit/utils/getDepositAnalyticsPayload.ts +++ b/app/components/UI/Ramp/Deposit/utils/getDepositAnalyticsPayload.ts @@ -63,6 +63,7 @@ function getDepositAnalyticsPayload( currency_destination_symbol: order?.cryptoCurrency?.symbol, currency_destination_network: getNetworkName(order), currency_source: order?.fiatCurrency || '', + provider_onramp: order?.provider || '', }; if (fiatOrder.state === FIAT_ORDER_STATES.COMPLETED) { diff --git a/app/components/UI/Ramp/components/BuildQuote/BuildQuote.test.tsx b/app/components/UI/Ramp/components/BuildQuote/BuildQuote.test.tsx index 9e10f79d281..0bfc61ef854 100644 --- a/app/components/UI/Ramp/components/BuildQuote/BuildQuote.test.tsx +++ b/app/components/UI/Ramp/components/BuildQuote/BuildQuote.test.tsx @@ -93,11 +93,19 @@ let mockTokens: { topTokens: [createMockToken()], }; +jest.mock('../../hooks/useRampsTokens', () => ({ + useRampsTokens: () => ({ + selectedToken: mockTokens?.allTokens?.[0] ?? null, + }), +})); + jest.mock('../../hooks/useRampsController', () => ({ useRampsController: () => ({ userRegion: mockUserRegion, selectedProvider: mockSelectedProvider, - tokens: mockTokens, + selectedToken: mockTokens?.allTokens?.[0] ?? null, + paymentMethodsLoading: false, + selectedPaymentMethod: null, }), })); @@ -199,7 +207,20 @@ describe('BuildQuote', () => { const { getByTestId, getByText } = renderWithTheme(); expect(getByTestId('payment-method-pill')).toBeOnTheScreen(); - expect(getByText('fiat_on_ramp.debit_card')).toBeOnTheScreen(); + expect(getByText('fiat_on_ramp.select_payment_method')).toBeOnTheScreen(); + }); + + it('navigates to payment selection modal when payment method pill is pressed', () => { + const { getByTestId } = renderWithTheme(); + + fireEvent.press(getByTestId('payment-method-pill')); + + expect(mockNavigate).toHaveBeenCalledWith( + 'RampModals', + expect.objectContaining({ + screen: 'RampPaymentSelectionModal', + }), + ); }); it('sets navigation options with undefined values when token is not found (shows skeleton)', () => { diff --git a/app/components/UI/Ramp/components/BuildQuote/BuildQuote.tsx b/app/components/UI/Ramp/components/BuildQuote/BuildQuote.tsx index 4db68438fa4..f49da9b3c7c 100644 --- a/app/components/UI/Ramp/components/BuildQuote/BuildQuote.tsx +++ b/app/components/UI/Ramp/components/BuildQuote/BuildQuote.tsx @@ -17,10 +17,7 @@ import { } from '@metamask/design-system-react-native'; import { strings } from '../../../../../../locales/i18n'; -import { - createNavigationDetails, - useParams, -} from '../../../../../util/navigation/navUtils'; +import { createNavigationDetails } from '../../../../../util/navigation/navUtils'; import { getRampsBuildQuoteNavbarOptions } from '../../../Navbar'; import Routes from '../../../../../constants/navigation/Routes'; import { useStyles } from '../../../../hooks/useStyles'; @@ -29,6 +26,7 @@ import { formatCurrency } from '../../utils/formatCurrency'; import { useTokenNetworkInfo } from '../../hooks/useTokenNetworkInfo'; import { useRampsController } from '../../hooks/useRampsController'; import { createSettingsModalNavDetails } from '../Modals/SettingsModal'; +import { createPaymentSelectionModalNavigationDetails } from '../PaymentSelectionModal'; interface BuildQuoteParams { assetId?: string; @@ -40,27 +38,22 @@ export const createBuildQuoteNavDetails = function BuildQuote() { const navigation = useNavigation(); const { styles } = useStyles(styleSheet, {}); - const { assetId } = useParams(); - const [amount, setAmount] = useState('0'); const [amountAsNumber, setAmountAsNumber] = useState(0); - // Get user region, selected provider, and tokens from RampsController - const { userRegion, selectedProvider, tokens } = useRampsController(); + const { + userRegion, + selectedProvider, + selectedToken, + paymentMethodsLoading, + selectedPaymentMethod, + } = useRampsController(); - // Get currency and quick amounts from user's region const currency = userRegion?.country?.currency || 'USD'; const quickAmounts = userRegion?.country?.quickAmounts ?? [50, 100, 200, 400]; - // Get network info helper const getTokenNetworkInfo = useTokenNetworkInfo(); - // Find the selected token from assetId param - const selectedToken = useMemo(() => { - if (!assetId || !tokens?.allTokens) return null; - return tokens.allTokens.find((token) => token.assetId === assetId) ?? null; - }, [assetId, tokens?.allTokens]); - // Get network info for the selected token const networkInfo = useMemo(() => { if (!selectedToken) return null; @@ -119,9 +112,15 @@ function BuildQuote() { })} { - // TODO: Open payment method selector + navigation.navigate( + ...createPaymentSelectionModalNavigationDetails(), + ); }} /> diff --git a/app/components/UI/Ramp/components/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap b/app/components/UI/Ramp/components/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap index 8817031a68a..b3893f75ba4 100644 --- a/app/components/UI/Ramp/components/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap +++ b/app/components/UI/Ramp/components/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap @@ -89,6 +89,7 @@ exports[`BuildQuote matches snapshot 1`] = ` - fiat_on_ramp.debit_card + fiat_on_ramp.select_payment_method { expect(mockOnPress).toHaveBeenCalledTimes(1); }); + it('does not call onPress when isLoading is true', () => { + const mockOnPress = jest.fn(); + const { getByTestId } = renderWithTheme( + , + ); + + fireEvent.press(getByTestId('payment-method-pill')); + + expect(mockOnPress).not.toHaveBeenCalled(); + }); + it('renders without onPress handler', () => { const { getByTestId } = renderWithTheme( , @@ -57,4 +72,12 @@ describe('PaymentMethodPill', () => { expect(toJSON()).toMatchSnapshot(); }); + + it('matches snapshot when loading', () => { + const { toJSON } = renderWithTheme( + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); }); diff --git a/app/components/UI/Ramp/components/PaymentMethodPill/PaymentMethodPill.tsx b/app/components/UI/Ramp/components/PaymentMethodPill/PaymentMethodPill.tsx index 50457ad3fd8..00f0104c734 100644 --- a/app/components/UI/Ramp/components/PaymentMethodPill/PaymentMethodPill.tsx +++ b/app/components/UI/Ramp/components/PaymentMethodPill/PaymentMethodPill.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { TouchableOpacity, View } from 'react-native'; +import { TouchableOpacity, View, ActivityIndicator } from 'react-native'; import Icon, { IconName, @@ -10,6 +10,7 @@ import Text, { TextVariant, } from '../../../../../component-library/components/Texts/Text'; import { useStyles } from '../../../../../component-library/hooks'; +import { useTheme } from '../../../../../util/theme'; import styleSheet from './PaymentMethodPill.styles'; @@ -18,6 +19,8 @@ export interface PaymentMethodPillProps { label: string; /** Optional press handler */ onPress?: () => void; + /** When true, shows loading indicator instead of carat and disables press */ + isLoading?: boolean; /** Test ID for testing */ testID?: string; } @@ -25,14 +28,17 @@ export interface PaymentMethodPillProps { const PaymentMethodPill: React.FC = ({ label, onPress, + isLoading = false, testID = 'payment-method-pill', }) => { const { styles } = useStyles(styleSheet, {}); + const { colors } = useTheme(); return ( @@ -47,11 +53,15 @@ const PaymentMethodPill: React.FC = ({ {label} - + {isLoading ? ( + + ) : ( + + )} ); diff --git a/app/components/UI/Ramp/components/PaymentMethodPill/__snapshots__/PaymentMethodPill.test.tsx.snap b/app/components/UI/Ramp/components/PaymentMethodPill/__snapshots__/PaymentMethodPill.test.tsx.snap index 8bec5ff9f98..b6de71c0b9f 100644 --- a/app/components/UI/Ramp/components/PaymentMethodPill/__snapshots__/PaymentMethodPill.test.tsx.snap +++ b/app/components/UI/Ramp/components/PaymentMethodPill/__snapshots__/PaymentMethodPill.test.tsx.snap @@ -3,6 +3,7 @@ exports[`PaymentMethodPill matches snapshot 1`] = ` `; + +exports[`PaymentMethodPill matches snapshot when loading 1`] = ` + + + + + + Select payment method + + + + + +`; diff --git a/app/components/UI/Ramp/components/PaymentSelectionModal/PaymentMethodListItem.test.tsx b/app/components/UI/Ramp/components/PaymentSelectionModal/PaymentMethodListItem.test.tsx new file mode 100644 index 00000000000..0542b037453 --- /dev/null +++ b/app/components/UI/Ramp/components/PaymentSelectionModal/PaymentMethodListItem.test.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react-native'; +import PaymentMethodListItem from './PaymentMethodListItem'; +import { ThemeContext, mockTheme } from '../../../../../util/theme'; +import type { PaymentMethod } from '@metamask/ramps-controller'; + +const renderWithTheme = (component: React.ReactElement) => + render( + + {component} + , + ); + +const mockPaymentMethod: PaymentMethod = { + id: '/payments/debit-credit-card', + paymentType: 'debit-credit-card', + name: 'Debit or Credit', + score: 90, + icon: 'card', + disclaimer: "Credit card purchases may incur your bank's cash advance fees.", + delay: [5, 10], + pendingOrderDescription: 'Card purchases may take a few minutes to complete.', +}; + +const mockPaymentMethodWithoutDelay: PaymentMethod = { + ...mockPaymentMethod, + delay: undefined, +}; + +describe('PaymentMethodListItem', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders payment method name', () => { + const { getByText } = renderWithTheme( + , + ); + + expect(getByText('Debit or Credit')).toBeOnTheScreen(); + }); + + it('renders delay text when delay array is provided', () => { + const { getByText } = renderWithTheme( + , + ); + + expect(getByText('5 - 10 mins')).toBeOnTheScreen(); + }); + + it('does not render delay text when not available', () => { + const { queryByText } = renderWithTheme( + , + ); + + expect(queryByText('5 - 10 mins')).not.toBeOnTheScreen(); + }); + + it('renders quote amounts', () => { + const { getByText } = renderWithTheme( + , + ); + + expect(getByText('0.10596 ETH')).toBeOnTheScreen(); + expect(getByText('~ $499.97')).toBeOnTheScreen(); + }); + + it('calls onPress when pressed', () => { + const mockOnPress = jest.fn(); + const { getByText } = renderWithTheme( + , + ); + + fireEvent.press(getByText('Debit or Credit')); + + expect(mockOnPress).toHaveBeenCalledTimes(1); + }); + + it('renders as selected when isSelected is true', () => { + const { toJSON } = renderWithTheme( + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('matches snapshot', () => { + const { toJSON } = renderWithTheme( + , + ); + expect(toJSON()).toMatchSnapshot(); + }); +}); diff --git a/app/components/UI/Ramp/components/PaymentSelectionModal/PaymentMethodListItem.tsx b/app/components/UI/Ramp/components/PaymentSelectionModal/PaymentMethodListItem.tsx new file mode 100644 index 00000000000..fdc90c5014a --- /dev/null +++ b/app/components/UI/Ramp/components/PaymentSelectionModal/PaymentMethodListItem.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { View, StyleSheet } from 'react-native'; +import ListItemSelect from '../../../../../component-library/components/List/ListItemSelect'; +import ListItemColumn, { + WidthType, +} from '../../../../../component-library/components/List/ListItemColumn'; +import Text, { + TextVariant, + TextColor, +} from '../../../../../component-library/components/Texts/Text'; +import { PaymentType } from '@consensys/on-ramp-sdk'; +import PaymentMethodIcon from '../../Aggregator/components/PaymentMethodIcon'; +import PaymentMethodQuote from './PaymentMethodQuote'; +import { formatDelayFromArray } from '../../Aggregator/utils'; +import { useTheme } from '../../../../../util/theme'; +import type { Colors } from '../../../../../util/theme/models'; +import type { PaymentMethod } from '@metamask/ramps-controller'; + +const ICON_CIRCLE_SIZE = 44; + +const createStyles = (colors: Colors) => + StyleSheet.create({ + iconCircle: { + width: ICON_CIRCLE_SIZE, + height: ICON_CIRCLE_SIZE, + borderRadius: ICON_CIRCLE_SIZE / 2, + backgroundColor: colors.background.muted, + justifyContent: 'center', + alignItems: 'center', + }, + iconCircleSelected: { + backgroundColor: colors.primary.muted, + }, + }); + +interface PaymentMethodListItemProps { + paymentMethod: PaymentMethod; + onPress?: () => void; + isSelected?: boolean; +} + +const PaymentMethodListItem: React.FC = ({ + paymentMethod, + onPress, + isSelected = false, +}) => { + const { colors } = useTheme(); + const styles = createStyles(colors); + const mockQuote = { + cryptoAmount: '0.10596 ETH', + fiatAmount: '~ $499.97', + }; + + const delayText = + Array.isArray(paymentMethod.delay) && paymentMethod.delay.length >= 2 + ? formatDelayFromArray(paymentMethod.delay) + : null; + + return ( + + + + + + + + {paymentMethod.name} + {delayText ? ( + + {delayText} + + ) : null} + + + + + + ); +}; + +export default PaymentMethodListItem; diff --git a/app/components/UI/Ramp/components/PaymentSelectionModal/PaymentMethodQuote.test.tsx b/app/components/UI/Ramp/components/PaymentSelectionModal/PaymentMethodQuote.test.tsx new file mode 100644 index 00000000000..627bf971f76 --- /dev/null +++ b/app/components/UI/Ramp/components/PaymentSelectionModal/PaymentMethodQuote.test.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import PaymentMethodQuote from './PaymentMethodQuote'; +import { ThemeContext, mockTheme } from '../../../../../util/theme'; + +const renderWithTheme = (component: React.ReactElement) => + render( + + {component} + , + ); + +describe('PaymentMethodQuote', () => { + it('renders crypto and fiat amounts', () => { + const { getByText } = renderWithTheme( + , + ); + + expect(getByText('0.10596 ETH')).toBeOnTheScreen(); + expect(getByText('~ $499.97')).toBeOnTheScreen(); + }); + + it('matches snapshot', () => { + const { toJSON } = renderWithTheme( + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); +}); diff --git a/app/components/UI/Ramp/components/PaymentSelectionModal/PaymentMethodQuote.tsx b/app/components/UI/Ramp/components/PaymentSelectionModal/PaymentMethodQuote.tsx new file mode 100644 index 00000000000..e9354268e67 --- /dev/null +++ b/app/components/UI/Ramp/components/PaymentSelectionModal/PaymentMethodQuote.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Box } from '@metamask/design-system-react-native'; +import Text, { + TextVariant, + TextColor, +} from '../../../../../component-library/components/Texts/Text'; + +interface PaymentMethodQuoteProps { + cryptoAmount: string; + fiatAmount: string; +} + +const PaymentMethodQuote: React.FC = ({ + cryptoAmount, + fiatAmount, +}) => ( + + {cryptoAmount} + + {fiatAmount} + + +); + +export default PaymentMethodQuote; diff --git a/app/components/UI/Ramp/components/PaymentSelectionModal/PaymentSelectionModal.styles.ts b/app/components/UI/Ramp/components/PaymentSelectionModal/PaymentSelectionModal.styles.ts new file mode 100644 index 00000000000..196481de061 --- /dev/null +++ b/app/components/UI/Ramp/components/PaymentSelectionModal/PaymentSelectionModal.styles.ts @@ -0,0 +1,41 @@ +import { StyleSheet } from 'react-native'; + +interface PaymentSelectionModalStyleSheetVars { + screenHeight: number; + screenWidth: number; +} + +const styleSheet = (params: { vars: PaymentSelectionModalStyleSheetVars }) => { + const { vars } = params; + const { screenHeight } = vars; + + return StyleSheet.create({ + list: { + flex: 1, + }, + containerOuter: { + height: screenHeight * 0.6, + overflow: 'hidden', + }, + containerInner: { + flexDirection: 'row', + width: '200%', + height: '100%', + }, + panel: { + width: '50%', + height: '100%', + }, + paymentPanelContent: { + flex: 1, + }, + footer: { + paddingHorizontal: 16, + paddingVertical: 16, + alignItems: 'center', + justifyContent: 'center', + }, + }); +}; + +export default styleSheet; diff --git a/app/components/UI/Ramp/components/PaymentSelectionModal/PaymentSelectionModal.test.tsx b/app/components/UI/Ramp/components/PaymentSelectionModal/PaymentSelectionModal.test.tsx new file mode 100644 index 00000000000..adb0d590e8e --- /dev/null +++ b/app/components/UI/Ramp/components/PaymentSelectionModal/PaymentSelectionModal.test.tsx @@ -0,0 +1,230 @@ +import React from 'react'; +import { fireEvent, waitFor } from '@testing-library/react-native'; +import PaymentSelectionModal from './PaymentSelectionModal'; +import { renderScreen } from '../../../../../util/test/renderWithProvider'; +import { backgroundState } from '../../../../../util/test/initial-root-state'; + +jest.mock('react-native-reanimated', () => { + const Reanimated = jest.requireActual('react-native-reanimated/mock'); + Reanimated.default.call = jest.fn(); + return { + ...Reanimated, + useAnimatedStyle: (styleFunc: () => object) => styleFunc(), + useSharedValue: (initialValue: number) => ({ value: initialValue }), + withTiming: (toValue: number) => toValue, + }; +}); + +jest.mock('../../../../Base/RemoteImage', () => jest.fn(() => null)); + +const mockOnCloseBottomSheet = jest.fn(); + +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheet', + () => { + const ReactActual = jest.requireActual('react'); + return ReactActual.forwardRef( + ( + { + children, + }: { + children: React.ReactNode; + }, + ref: React.Ref<{ onCloseBottomSheet: () => void }>, + ) => { + ReactActual.useImperativeHandle(ref, () => ({ + onCloseBottomSheet: mockOnCloseBottomSheet, + })); + return <>{children}; + }, + ); + }, +); + +jest.mock('../../../../../util/navigation/navUtils', () => ({ + createNavigationDetails: jest.fn(), +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string) => key, +})); + +jest.mock('react-native', () => { + const actualReactNative = jest.requireActual('react-native'); + return { + ...actualReactNative, + useWindowDimensions: () => ({ + width: 375, + height: 812, + }), + }; +}); + +const mockPaymentMethods = [ + { + id: '/payments/debit-credit-card-1', + paymentType: 'debit-credit-card', + name: 'Debit or Credit', + score: 90, + icon: 'card', + disclaimer: + "Credit card purchases may incur your bank's cash advance fees.", + delay: '5 to 10 minutes.', + pendingOrderDescription: + 'Card purchases may take a few minutes to complete.', + }, + { + id: '/payments/debit-credit-card-2', + paymentType: 'debit-credit-card', + name: 'Debit or Credit', + score: 90, + icon: 'card', + disclaimer: + "Credit card purchases may incur your bank's cash advance fees.", + delay: '5 to 10 minutes.', + pendingOrderDescription: + 'Card purchases may take a few minutes to complete.', + }, +]; + +const mockSelectedProvider = { + id: '/providers/transak', + name: 'Transak', + environmentType: 'PRODUCTION', + description: 'Test provider', + hqAddress: 'Test Address', + links: [], + logos: { + light: 'https://example.com/logo-light.png', + dark: 'https://example.com/logo-dark.png', + height: 24, + width: 90, + }, +}; + +const mockProviders = [ + mockSelectedProvider, + { + id: '/providers/moonpay', + name: 'MoonPay', + environmentType: 'PRODUCTION', + description: 'Test provider 2', + hqAddress: 'Test Address 2', + links: [], + logos: { + light: 'https://example.com/moonpay-light.png', + dark: 'https://example.com/moonpay-dark.png', + height: 24, + width: 90, + }, + }, +]; + +const mockSetSelectedProvider = jest.fn(); +const mockSetSelectedPaymentMethod = jest.fn(); + +jest.mock('../../hooks/useRampsController', () => ({ + useRampsController: () => ({ + selectedProvider: mockSelectedProvider, + providers: mockProviders, + paymentMethods: mockPaymentMethods, + setSelectedProvider: mockSetSelectedProvider, + setSelectedPaymentMethod: mockSetSelectedPaymentMethod, + }), +})); + +function renderWithProvider(component: React.ComponentType) { + return renderScreen( + component, + { + name: 'PaymentSelectionModal', + }, + { + state: { + engine: { + backgroundState, + }, + }, + }, + ); +} + +describe('PaymentSelectionModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('matches snapshot', () => { + const { toJSON } = renderWithProvider(PaymentSelectionModal); + expect(toJSON()).toMatchSnapshot(); + }); + + it('displays header with "Pay with" text', () => { + const { getByText } = renderWithProvider(PaymentSelectionModal); + + expect(getByText('fiat_on_ramp.pay_with')).toBeOnTheScreen(); + }); + + it('displays payment methods list', () => { + const { getAllByText } = renderWithProvider(PaymentSelectionModal); + + const paymentMethodNames = getAllByText('Debit or Credit'); + expect(paymentMethodNames.length).toBeGreaterThan(0); + }); + + it('calls onCloseBottomSheet when payment method is pressed', async () => { + const { getAllByText } = renderWithProvider(PaymentSelectionModal); + + const paymentMethodItems = getAllByText('Debit or Credit'); + fireEvent.press(paymentMethodItems[0]); + + await waitFor(() => { + expect(mockOnCloseBottomSheet).toHaveBeenCalled(); + expect(mockSetSelectedPaymentMethod).toHaveBeenCalledWith( + mockPaymentMethods[0], + ); + }); + }); + + it('navigates to provider selection when change provider is pressed', async () => { + const { getByText } = renderWithProvider(PaymentSelectionModal); + + const changeProviderLink = getByText('fiat_on_ramp.change_provider'); + fireEvent.press(changeProviderLink); + + await waitFor(() => { + expect(getByText('fiat_on_ramp_aggregator.providers')).toBeOnTheScreen(); + }); + }); + + it('displays provider selection when change provider is pressed', async () => { + const { getByText } = renderWithProvider(PaymentSelectionModal); + + const changeProviderLink = getByText('fiat_on_ramp.change_provider'); + fireEvent.press(changeProviderLink); + + await waitFor(() => { + expect(getByText('fiat_on_ramp_aggregator.providers')).toBeOnTheScreen(); + }); + }); + + it('returns to payment selection when back is pressed from provider selection', async () => { + const { getByText, getByTestId } = renderWithProvider( + PaymentSelectionModal, + ); + + const changeProviderLink = getByText('fiat_on_ramp.change_provider'); + fireEvent.press(changeProviderLink); + + await waitFor(() => { + expect(getByText('fiat_on_ramp_aggregator.providers')).toBeOnTheScreen(); + }); + + const backButton = getByTestId('button-icon'); + fireEvent.press(backButton); + + await waitFor(() => { + expect(getByText('fiat_on_ramp.pay_with')).toBeOnTheScreen(); + }); + }); +}); diff --git a/app/components/UI/Ramp/components/PaymentSelectionModal/PaymentSelectionModal.tsx b/app/components/UI/Ramp/components/PaymentSelectionModal/PaymentSelectionModal.tsx new file mode 100644 index 00000000000..aba046a076f --- /dev/null +++ b/app/components/UI/Ramp/components/PaymentSelectionModal/PaymentSelectionModal.tsx @@ -0,0 +1,182 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useWindowDimensions, View } from 'react-native'; +import { FlatList } from 'react-native-gesture-handler'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withTiming, + Easing, +} from 'react-native-reanimated'; +import BottomSheet, { + BottomSheetRef, +} from '../../../../../component-library/components/BottomSheets/BottomSheet'; +import { AnimationDuration } from '../../../../../component-library/constants/animation.constants'; +import Text, { + TextVariant, + TextColor, +} from '../../../../../component-library/components/Texts/Text'; +import { + Box, + BoxAlignItems, + BoxJustifyContent, +} from '@metamask/design-system-react-native'; +import { useStyles } from '../../../../hooks/useStyles'; +import { strings } from '../../../../../../locales/i18n'; +import styleSheet from './PaymentSelectionModal.styles'; +import Routes from '../../../../../constants/navigation/Routes'; +import { createNavigationDetails } from '../../../../../util/navigation/navUtils'; +import PaymentMethodListItem from './PaymentMethodListItem'; +import ProviderSelection from './ProviderSelection'; +import type { PaymentMethod, Provider } from '@metamask/ramps-controller'; +import { useRampsController } from '../../hooks/useRampsController'; + +export const createPaymentSelectionModalNavigationDetails = + createNavigationDetails( + Routes.RAMP.MODALS.ID, + Routes.RAMP.MODALS.PAYMENT_SELECTION, + ); + +enum ViewType { + PAYMENT = 'PAYMENT', + PROVIDER = 'PROVIDER', +} + +function PaymentSelectionModal() { + const sheetRef = useRef(null); + const { width: screenWidth, height: screenHeight } = useWindowDimensions(); + const { styles } = useStyles(styleSheet, { + screenHeight, + screenWidth, + }); + + const { + selectedProvider, + setSelectedProvider, + providers, + paymentMethods, + selectedPaymentMethod, + setSelectedPaymentMethod, + } = useRampsController(); + + const [activeView, setActiveView] = useState(ViewType.PAYMENT); + const translateX = useSharedValue(0); + + useEffect(() => { + const animationConfig = { + duration: AnimationDuration.Regularly, + easing: Easing.out(Easing.ease), + }; + + if (activeView === ViewType.PROVIDER) { + translateX.value = withTiming(-screenWidth, animationConfig); + } else { + translateX.value = withTiming(0, animationConfig); + } + }, [activeView, screenWidth, translateX]); + + const animatedContainerStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: translateX.value }], + })); + + const handleChangeProviderPress = useCallback(() => { + setActiveView(ViewType.PROVIDER); + }, []); + + const handleProviderBack = useCallback(() => { + setActiveView(ViewType.PAYMENT); + }, []); + + const handleProviderSelect = useCallback( + (provider: Provider) => { + setSelectedProvider(provider); + setActiveView(ViewType.PAYMENT); + }, + [setSelectedProvider], + ); + + const handlePaymentMethodPress = useCallback( + (paymentMethod: PaymentMethod) => { + setSelectedPaymentMethod(paymentMethod); + sheetRef.current?.onCloseBottomSheet(); + }, + [setSelectedPaymentMethod], + ); + + const renderPaymentMethod = useCallback( + ({ item: paymentMethod }: { item: PaymentMethod }) => ( + handlePaymentMethodPress(paymentMethod)} + isSelected={selectedPaymentMethod?.id === paymentMethod.id} + /> + ), + [handlePaymentMethodPress, selectedPaymentMethod], + ); + + const renderListContent = () => ( + item.id} + keyboardDismissMode="none" + keyboardShouldPersistTaps="always" + /> + ); + + return ( + + + + + + + + {strings('fiat_on_ramp.pay_with')} + + + {renderListContent()} + + {selectedProvider ? ( + + + {strings('fiat_on_ramp.buying_via', { + providerName: selectedProvider.name, + })}{' '} + + {strings('fiat_on_ramp.change_provider')} + + + + ) : null} + + + + + + + + ); +} + +export default PaymentSelectionModal; diff --git a/app/components/UI/Ramp/components/PaymentSelectionModal/ProviderSelection.test.tsx b/app/components/UI/Ramp/components/PaymentSelectionModal/ProviderSelection.test.tsx new file mode 100644 index 00000000000..6f79769edb0 --- /dev/null +++ b/app/components/UI/Ramp/components/PaymentSelectionModal/ProviderSelection.test.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; +import ProviderSelection from './ProviderSelection'; +import { renderScreen } from '../../../../../util/test/renderWithProvider'; +import { backgroundState } from '../../../../../util/test/initial-root-state'; +import type { Provider } from '@metamask/ramps-controller'; + +const mockProviders: Provider[] = [ + { + id: '/providers/transak', + name: 'Transak', + environmentType: 'PRODUCTION', + description: 'Test provider', + hqAddress: 'Test Address', + links: [], + logos: { + light: 'https://example.com/logo-light.png', + dark: 'https://example.com/logo-dark.png', + height: 24, + width: 90, + }, + }, +]; + +const mockOnBack = jest.fn(); + +function renderWithProvider( + providers: Provider[] = mockProviders, + selectedProvider: Provider | null = mockProviders[0], +) { + return renderScreen( + () => ( + + ), + { + name: 'ProviderSelection', + }, + { + state: { + engine: { + backgroundState, + }, + }, + }, + ); +} + +describe('ProviderSelection', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('matches snapshot', () => { + const { toJSON } = renderWithProvider(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('calls onBack when back button is pressed', () => { + const { getByTestId } = renderWithProvider(); + fireEvent.press(getByTestId('button-icon')); + expect(mockOnBack).toHaveBeenCalled(); + }); +}); diff --git a/app/components/UI/Ramp/components/PaymentSelectionModal/ProviderSelection.tsx b/app/components/UI/Ramp/components/PaymentSelectionModal/ProviderSelection.tsx new file mode 100644 index 00000000000..0930fe2bf36 --- /dev/null +++ b/app/components/UI/Ramp/components/PaymentSelectionModal/ProviderSelection.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { Box } from '@metamask/design-system-react-native'; +import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; +import type { Provider } from '@metamask/ramps-controller'; +import { strings } from '../../../../../../locales/i18n'; + +interface ProviderSelectionProps { + providers: Provider[]; + selectedProvider: Provider | null; + onProviderSelect: (provider: Provider) => void; + onBack: () => void; +} + +const ProviderSelection: React.FC = ({ onBack }) => ( + + + +); + +export default ProviderSelection; diff --git a/app/components/UI/Ramp/components/PaymentSelectionModal/__snapshots__/PaymentMethodListItem.test.tsx.snap b/app/components/UI/Ramp/components/PaymentSelectionModal/__snapshots__/PaymentMethodListItem.test.tsx.snap new file mode 100644 index 00000000000..4284fbb4198 --- /dev/null +++ b/app/components/UI/Ramp/components/PaymentSelectionModal/__snapshots__/PaymentMethodListItem.test.tsx.snap @@ -0,0 +1,390 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PaymentMethodListItem matches snapshot 1`] = ` + + + + + + +  + + + + + + + Debit or Credit + + + 5 - 10 mins + + + + + + + 0.10596 ETH + + + ~ $499.97 + + + + + + +`; + +exports[`PaymentMethodListItem renders as selected when isSelected is true 1`] = ` + + + + + + +  + + + + + + + Debit or Credit + + + 5 - 10 mins + + + + + + + 0.10596 ETH + + + ~ $499.97 + + + + + + + +`; diff --git a/app/components/UI/Ramp/components/PaymentSelectionModal/__snapshots__/PaymentMethodQuote.test.tsx.snap b/app/components/UI/Ramp/components/PaymentSelectionModal/__snapshots__/PaymentMethodQuote.test.tsx.snap new file mode 100644 index 00000000000..d16e675aca0 --- /dev/null +++ b/app/components/UI/Ramp/components/PaymentSelectionModal/__snapshots__/PaymentMethodQuote.test.tsx.snap @@ -0,0 +1,44 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PaymentMethodQuote matches snapshot 1`] = ` + + + 0.10596 ETH + + + ~ $499.97 + + +`; diff --git a/app/components/UI/Ramp/components/PaymentSelectionModal/__snapshots__/PaymentSelectionModal.test.tsx.snap b/app/components/UI/Ramp/components/PaymentSelectionModal/__snapshots__/PaymentSelectionModal.test.tsx.snap new file mode 100644 index 00000000000..d0a1b12c4ad --- /dev/null +++ b/app/components/UI/Ramp/components/PaymentSelectionModal/__snapshots__/PaymentSelectionModal.test.tsx.snap @@ -0,0 +1,1034 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PaymentSelectionModal matches snapshot 1`] = ` + + + + + + + + + + + + + PaymentSelectionModal + + + + + + + + + + + + + + + + + + + + + + + fiat_on_ramp.pay_with + + + + + + + + + + + +  + + + + + + + Debit or Credit + + + + + + + 0.10596 ETH + + + ~ $499.97 + + + + + + + + + + + + + + +  + + + + + + + Debit or Credit + + + + + + + 0.10596 ETH + + + ~ $499.97 + + + + + + + + + + + + + fiat_on_ramp.buying_via + + + fiat_on_ramp.change_provider + + + + + + + + + + + + + + + + + + + + fiat_on_ramp_aggregator.providers + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/app/components/UI/Ramp/components/PaymentSelectionModal/__snapshots__/ProviderSelection.test.tsx.snap b/app/components/UI/Ramp/components/PaymentSelectionModal/__snapshots__/ProviderSelection.test.tsx.snap new file mode 100644 index 00000000000..52783547cf5 --- /dev/null +++ b/app/components/UI/Ramp/components/PaymentSelectionModal/__snapshots__/ProviderSelection.test.tsx.snap @@ -0,0 +1,492 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ProviderSelection matches snapshot 1`] = ` + + + + + + + + + + + + + ProviderSelection + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + [missing "en.fiat_on_ramp_aggregator.providers" translation] + + + + + + + + + + + + + + + + + + +`; diff --git a/app/components/UI/Ramp/components/PaymentSelectionModal/index.ts b/app/components/UI/Ramp/components/PaymentSelectionModal/index.ts new file mode 100644 index 00000000000..03d9dfd412a --- /dev/null +++ b/app/components/UI/Ramp/components/PaymentSelectionModal/index.ts @@ -0,0 +1,2 @@ +export { default } from './PaymentSelectionModal'; +export { createPaymentSelectionModalNavigationDetails } from './PaymentSelectionModal'; diff --git a/app/components/UI/Ramp/components/TokenSelection/__snapshots__/TokenSelection.test.tsx.snap b/app/components/UI/Ramp/components/TokenSelection/__snapshots__/TokenSelection.test.tsx.snap index 58646df86ec..5c301f44d6e 100644 --- a/app/components/UI/Ramp/components/TokenSelection/__snapshots__/TokenSelection.test.tsx.snap +++ b/app/components/UI/Ramp/components/TokenSelection/__snapshots__/TokenSelection.test.tsx.snap @@ -658,12 +658,12 @@ exports[`TokenSelection Component displays empty state when no tokens match sear style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc866", + "borderRadius": 24, "borderWidth": 1, "flexDirection": "row", - "height": 40, + "height": 48, "opacity": 1, "paddingHorizontal": 16, } @@ -673,23 +673,23 @@ exports[`TokenSelection Component displays empty state when no tokens match sear @@ -1517,12 +1519,12 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc866", + "borderRadius": 24, "borderWidth": 1, "flexDirection": "row", - "height": 40, + "height": 48, "opacity": 1, "paddingHorizontal": 16, } @@ -1532,23 +1534,23 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena ({ }, })); +jest.mock( + '../../../../selectors/multichainAccounts/accountTreeController', + () => ({ + ...jest.requireActual( + '../../../../selectors/multichainAccounts/accountTreeController', + ), + selectSelectedAccountGroupWithInternalAccountsAddresses: () => [], + }), +); + jest.mock('../utils/determinePreferredProvider', () => ({ determinePreferredProvider: jest.fn(), })); @@ -23,7 +33,7 @@ jest.mock('../utils/determinePreferredProvider', () => ({ const emptyOrders: FiatOrder[] = []; jest.mock('../../../../reducers/fiatOrders', () => ({ ...jest.requireActual('../../../../reducers/fiatOrders'), - getOrders: jest.fn((_state: unknown) => emptyOrders), + getOrders: jest.fn((_state: unknown) => []), })); const mockProviders: RampProvider[] = [ @@ -73,6 +83,9 @@ const createMockStore = (providersState = {}) => }, }, }), + fiatOrders: () => ({ + orders: [], + }), }, }); diff --git a/app/components/UI/Ramp/routes.tsx b/app/components/UI/Ramp/routes.tsx index 533f0992e79..f4e57be2650 100644 --- a/app/components/UI/Ramp/routes.tsx +++ b/app/components/UI/Ramp/routes.tsx @@ -5,6 +5,7 @@ import TokenSelection from './components/TokenSelection'; import BuildQuote from './components/BuildQuote'; import UnsupportedTokenModal from './components/UnsupportedTokenModal'; import SettingsModal from './components/Modals/SettingsModal'; +import PaymentSelectionModal from './components/PaymentSelectionModal'; const RootStack = createStackNavigator(); const Stack = createStackNavigator(); @@ -44,6 +45,10 @@ const TokenListModalsRoutes = () => ( name={Routes.RAMP.MODALS.BUILD_QUOTE_SETTINGS} component={SettingsModal} /> + ); diff --git a/app/components/UI/Rewards/components/EndOfSeasonClaimBottomSheet/EndOfSeasonClaimBottomSheet.test.tsx b/app/components/UI/Rewards/components/EndOfSeasonClaimBottomSheet/EndOfSeasonClaimBottomSheet.test.tsx index 3b547080f21..ded9c90a707 100644 --- a/app/components/UI/Rewards/components/EndOfSeasonClaimBottomSheet/EndOfSeasonClaimBottomSheet.test.tsx +++ b/app/components/UI/Rewards/components/EndOfSeasonClaimBottomSheet/EndOfSeasonClaimBottomSheet.test.tsx @@ -344,9 +344,6 @@ jest.mock('../../../../../component-library/components/Form/TextField', () => { editable: !isDisabled, accessibilityLabel: `isError:${isError},isDisabled:${isDisabled}`, }), - TextFieldSize: { - Lg: 'Lg', - }, }; }); diff --git a/app/components/UI/Rewards/components/EndOfSeasonClaimBottomSheet/EndOfSeasonClaimBottomSheet.tsx b/app/components/UI/Rewards/components/EndOfSeasonClaimBottomSheet/EndOfSeasonClaimBottomSheet.tsx index 44836c07a14..f747804946e 100644 --- a/app/components/UI/Rewards/components/EndOfSeasonClaimBottomSheet/EndOfSeasonClaimBottomSheet.tsx +++ b/app/components/UI/Rewards/components/EndOfSeasonClaimBottomSheet/EndOfSeasonClaimBottomSheet.tsx @@ -47,9 +47,7 @@ import AvatarAccount from '../../../../../component-library/components/Avatars/A import { AvatarSize } from '../../../../../component-library/components/Avatars/Avatar'; import { createAccountSelectorNavDetails } from '../../../../Views/AccountSelector'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import TextField, { - TextFieldSize, -} from '../../../../../component-library/components/Form/TextField'; +import TextField from '../../../../../component-library/components/Form/TextField'; import useClaimReward from '../../hooks/useClaimReward'; import useLineaSeasonOneTokenReward from '../../hooks/useLineaSeasonOneTokenReward'; import { validateEmail } from '../../utils/formatUtils'; @@ -327,7 +325,6 @@ const EndOfSeasonClaimBottomSheet = ({ onChangeText={handleEmailChange} value={email} isError={emailValidationError} - size={TextFieldSize.Lg} style={tw.style( 'bg-background-pressed', emailValidationError && 'border-error-default', @@ -365,7 +362,6 @@ const EndOfSeasonClaimBottomSheet = ({ placeholder={strings('rewards.metal_card_claim.telegram_placeholder')} onChangeText={setTelegram} value={telegram} - size={TextFieldSize.Lg} style={tw.style('bg-background-pressed')} autoCapitalize="none" isDisabled={isClaimingReward} diff --git a/app/components/UI/Rewards/components/Onboarding/OnboardingStep4.tsx b/app/components/UI/Rewards/components/Onboarding/OnboardingStep4.tsx index 1ab717427af..179b51799bc 100644 --- a/app/components/UI/Rewards/components/Onboarding/OnboardingStep4.tsx +++ b/app/components/UI/Rewards/components/Onboarding/OnboardingStep4.tsx @@ -17,9 +17,7 @@ import { } from '@metamask/design-system-react-native'; import Checkbox from '../../../../../component-library/components/Checkbox'; import step4Img from '../../../../../images/rewards/rewards-onboarding-step4.png'; -import TextField, { - TextFieldSize, -} from '../../../../../component-library/components/Form/TextField'; +import TextField from '../../../../../component-library/components/Form/TextField'; import { strings } from '../../../../../../locales/i18n'; import OnboardingStepComponent from './OnboardingStep'; import { selectRewardsSubscriptionId } from '../../../../../selectors/rewards'; @@ -137,7 +135,6 @@ const OnboardingStep4: React.FC = () => { autoCapitalize="characters" onChangeText={handleReferralCodeChange} isDisabled={optinLoading} - size={TextFieldSize.Lg} style={tw.style( 'bg-background-pressed', referralCode.length >= 6 && diff --git a/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonUnlockedRewards.test.tsx b/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonUnlockedRewards.test.tsx index b290c9957e7..06c06753940 100644 --- a/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonUnlockedRewards.test.tsx +++ b/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonUnlockedRewards.test.tsx @@ -461,6 +461,12 @@ describe('PreviousSeasonUnlockedRewards', () => { }, }; + const mockOthersideUnlockedRewardWithoutUrl: RewardDto = { + id: 'reward-otherside-without-url', + seasonRewardId: 'season-reward-otherside', + claimStatus: RewardClaimStatus.UNCLAIMED, + }; + const mockSeasonTiers = [ { id: 'tier-1', @@ -1033,4 +1039,72 @@ describe('PreviousSeasonUnlockedRewards', () => { // OTHERSIDE requires manual claim, so isLocked is false expect(rewardItem.props.accessibilityLabel).toContain('isLocked:false'); }); + + it('passes isLocked=true for OTHERSIDE reward without URL', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectUnlockedRewards) + return [mockOthersideUnlockedRewardWithoutUrl]; + if (selector === selectUnlockedRewardLoading) return false; + if (selector === selectUnlockedRewardError) return false; + if (selector === selectSeasonTiers) return mockSeasonTiers; + if (selector === selectCurrentTier) return { pointsNeeded: 100 }; + return undefined; + }); + + const { getByTestId } = render(); + + const rewardItem = getByTestId('reward-item-reward-otherside-without-url'); + // OTHERSIDE without URL is locked since there's no URL to claim and it's not a redeemable reward + expect(rewardItem.props.accessibilityLabel).toContain('isLocked:true'); + }); + + it('navigates to end of season claim sheet when OTHERSIDE reward without URL is pressed', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectUnlockedRewards) + return [mockOthersideUnlockedRewardWithoutUrl]; + if (selector === selectUnlockedRewardLoading) return false; + if (selector === selectUnlockedRewardError) return false; + if (selector === selectSeasonTiers) return mockSeasonTiers; + if (selector === selectCurrentTier) return { pointsNeeded: 100 }; + return undefined; + }); + + const { getByTestId } = render(); + + const rewardItem = getByTestId('reward-item-reward-otherside-without-url'); + rewardItem.props.onPress(); + + expect(mockNavigate).toHaveBeenCalledWith('EndOfSeasonClaimBottomSheet', { + rewardId: mockOthersideUnlockedRewardWithoutUrl.id, + seasonRewardId: 'season-reward-otherside', + title: 'Otherside Reward', + description: 'Otherside long unlocked', + url: undefined, + rewardType: SeasonRewardType.OTHERSIDE, + }); + }); + + it('shows no rewards message when currentTier has no pointsNeeded and empty unlocked rewards', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectUnlockedRewards) return []; + if (selector === selectUnlockedRewardLoading) return false; + if (selector === selectUnlockedRewardError) return false; + if (selector === selectSeasonTiers) return mockSeasonTiers; + if (selector === selectCurrentTier) return { pointsNeeded: 0 }; + return undefined; + }); + + const { getByTestId, getByText } = render( + , + ); + + expect( + getByTestId('rewards-season-ended-no-unlocked-rewards-image'), + ).toBeOnTheScreen(); + expect( + getByText( + "You didn't earn rewards this season, but there's always next time.", + ), + ).toBeOnTheScreen(); + }); }); diff --git a/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonUnlockedRewards.tsx b/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonUnlockedRewards.tsx index 8c1c35e834b..be27b673012 100644 --- a/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonUnlockedRewards.tsx +++ b/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonUnlockedRewards.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useMemo } from 'react'; +import { FlatList } from 'react-native'; import { Box, BoxFlexDirection, @@ -144,7 +145,10 @@ const PreviousSeasonUnlockedRewards = () => { } return ( - + { flexDirection={BoxFlexDirection.Column} twClassName="gap-4 w-full" > - - {endOfSeasonRewards?.map((unlockedReward: RewardDto) => { + item.id} + showsVerticalScrollIndicator={false} + nestedScrollEnabled + style={tw.style('w-full')} + contentContainerStyle={tw.style('gap-4 pb-60')} + renderItem={({ item: unlockedReward, index }) => { const seasonReward = seasonTiers ?.flatMap((tier) => tier.rewards) ?.find( @@ -190,28 +200,31 @@ const PreviousSeasonUnlockedRewards = () => { | undefined )?.url; + const isLast = index === endOfSeasonRewards.length - 1; + return ( - + + + ); - })} - + }} + /> ) : ( <> diff --git a/app/components/UI/Rewards/components/Settings/ReferredByCodeSection.test.tsx b/app/components/UI/Rewards/components/Settings/ReferredByCodeSection.test.tsx index 1224c06f4f0..6287d9926a0 100644 --- a/app/components/UI/Rewards/components/Settings/ReferredByCodeSection.test.tsx +++ b/app/components/UI/Rewards/components/Settings/ReferredByCodeSection.test.tsx @@ -109,11 +109,6 @@ jest.mock('../../../../../component-library/components/Form/TextField', () => { endAccessory, ), ), - TextFieldSize: { - Sm: '32', - Md: '40', - Lg: '48', - }, }; }); diff --git a/app/components/UI/Rewards/components/Settings/ReferredByCodeSection.tsx b/app/components/UI/Rewards/components/Settings/ReferredByCodeSection.tsx index 5c165fd3b28..c1d913e6a78 100644 --- a/app/components/UI/Rewards/components/Settings/ReferredByCodeSection.tsx +++ b/app/components/UI/Rewards/components/Settings/ReferredByCodeSection.tsx @@ -16,9 +16,7 @@ import { selectReferralDetailsLoading, selectReferralDetailsError, } from '../../../../../reducers/rewards/selectors'; -import TextField, { - TextFieldSize, -} from '../../../../../component-library/components/Form/TextField'; +import TextField from '../../../../../component-library/components/Form/TextField'; import { useReferralDetails } from '../../hooks/useReferralDetails'; import { useValidateReferralCode } from '../../hooks/useValidateReferralCode'; import { useApplyReferralCode } from '../../hooks/useApplyReferralCode'; @@ -199,7 +197,6 @@ const ReferredByCodeSection: React.FC = () => { ? colors.error.default : colors.border.muted, }} - size={TextFieldSize.Lg} endAccessory={renderIcon()} isError={showClientValidationError || Boolean(applyReferralCodeError)} /> diff --git a/app/components/UI/Rewards/components/Tabs/LevelsTab/RewardsClaimBottomSheetModal.test.tsx b/app/components/UI/Rewards/components/Tabs/LevelsTab/RewardsClaimBottomSheetModal.test.tsx index a57e8ed9738..022e31539ce 100644 --- a/app/components/UI/Rewards/components/Tabs/LevelsTab/RewardsClaimBottomSheetModal.test.tsx +++ b/app/components/UI/Rewards/components/Tabs/LevelsTab/RewardsClaimBottomSheetModal.test.tsx @@ -123,16 +123,9 @@ jest.mock( value, }); - TextFieldComponent.Size = { - Lg: 'lg', - }; - return { __esModule: true, default: TextFieldComponent, - TextFieldSize: { - Lg: 'lg', - }, }; }, ); diff --git a/app/components/UI/Rewards/components/Tabs/LevelsTab/RewardsClaimBottomSheetModal.tsx b/app/components/UI/Rewards/components/Tabs/LevelsTab/RewardsClaimBottomSheetModal.tsx index 37efd04fa45..1a6d4a45300 100644 --- a/app/components/UI/Rewards/components/Tabs/LevelsTab/RewardsClaimBottomSheetModal.tsx +++ b/app/components/UI/Rewards/components/Tabs/LevelsTab/RewardsClaimBottomSheetModal.tsx @@ -34,9 +34,7 @@ import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { REWARDS_VIEW_SELECTORS } from '../../../Views/RewardsView.constants'; import { Linking, TouchableOpacity } from 'react-native'; import { formatUrl } from '../../../utils/formatUtils'; -import TextField, { - TextFieldSize, -} from '../../../../../../component-library/components/Form/TextField'; +import TextField from '../../../../../../component-library/components/Form/TextField'; import useRewardsToast from '../../../hooks/useRewardsToast'; import RewardsErrorBanner from '../../RewardsErrorBanner'; import { MetaMetricsEvents, useMetrics } from '../../../../../hooks/useMetrics'; @@ -304,7 +302,6 @@ const RewardsClaimBottomSheetModal = ({ placeholder={inputPlaceholder} onChangeText={setInputValue} value={inputValue} - size={TextFieldSize.Lg} style={tw.style('bg-background-pressed my-4')} /> ); diff --git a/app/components/UI/SeedphraseModal/index.js b/app/components/UI/SeedphraseModal/index.js index 8ba0cb75ad5..faf2d5bb20d 100644 --- a/app/components/UI/SeedphraseModal/index.js +++ b/app/components/UI/SeedphraseModal/index.js @@ -13,7 +13,7 @@ import Button, { ButtonWidthTypes, ButtonSize, } from '../../../component-library/components/Buttons/Button'; -import HeaderCenter from '../../../component-library/components-temp/HeaderCenter'; +import HeaderCompactStandard from '../../../component-library/components-temp/HeaderCompactStandard'; import { useNavigation } from '@react-navigation/native'; const createStyles = (colors) => @@ -68,7 +68,7 @@ const SeedphraseModal = () => { return ( - diff --git a/app/components/UI/SelectComponent/index.js b/app/components/UI/SelectComponent/index.js index 1b13a8ff925..23594d9ae10 100644 --- a/app/components/UI/SelectComponent/index.js +++ b/app/components/UI/SelectComponent/index.js @@ -14,7 +14,7 @@ import dismissKeyboard from 'react-native/Libraries/Utilities/dismissKeyboard'; import IconCheck from 'react-native-vector-icons/MaterialCommunityIcons'; import Device from '../../../util/device'; import { ThemeContext, mockTheme } from '../../../util/theme'; -import HeaderCenter from '../../../component-library/components-temp/HeaderCenter'; +import HeaderCompactStandard from '../../../component-library/components-temp/HeaderCompactStandard'; const ROW_HEIGHT = 35; const createStyles = (colors) => @@ -182,7 +182,10 @@ export default class SelectComponent extends PureComponent { backdropOpacity={1} > - + {this.props.options.map((option) => ( diff --git a/app/components/UI/SrpInputGrid/__snapshots__/index.test.tsx.snap b/app/components/UI/SrpInputGrid/__snapshots__/index.test.tsx.snap index f393bdfd389..410486bcfb6 100644 --- a/app/components/UI/SrpInputGrid/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/SrpInputGrid/__snapshots__/index.test.tsx.snap @@ -60,9 +60,9 @@ exports[`SrpInputGrid renders with custom uniqueId 1`] = ` style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc866", + "borderRadius": 12, "borderWidth": 1, "flexDirection": "row", "height": 0, @@ -80,7 +80,7 @@ exports[`SrpInputGrid renders with custom uniqueId 1`] = ` [ { "backgroundColor": "inherit", - "height": 38, + "height": 46, }, { "flex": 1, @@ -159,13 +159,13 @@ exports[`SrpInputGrid renders with custom uniqueId 1`] = ` { "alignItems": "center", "backgroundColor": "transparent", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "borderColor": "#b7bbc866", + "borderRadius": 12, "borderWidth": 0, "display": "flex", "flex": 1, "flexDirection": "row", - "height": 40, + "height": 48, "opacity": 1, "paddingHorizontal": 0, } @@ -177,7 +177,7 @@ exports[`SrpInputGrid renders with custom uniqueId 1`] = ` [ { "backgroundColor": "inherit", - "height": 38, + "height": 46, }, { "flex": 1, @@ -319,9 +319,9 @@ exports[`SrpInputGrid renders with disabled state 1`] = ` style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc866", + "borderRadius": 12, "borderWidth": 1, "flexDirection": "row", "height": 0, @@ -339,7 +339,7 @@ exports[`SrpInputGrid renders with disabled state 1`] = ` [ { "backgroundColor": "inherit", - "height": 38, + "height": 46, }, { "flex": 1, @@ -418,13 +418,13 @@ exports[`SrpInputGrid renders with disabled state 1`] = ` { "alignItems": "center", "backgroundColor": "transparent", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "borderColor": "#b7bbc866", + "borderRadius": 12, "borderWidth": 0, "display": "flex", "flex": 1, "flexDirection": "row", - "height": 40, + "height": 48, "opacity": 0.5, "paddingHorizontal": 0, } @@ -436,7 +436,7 @@ exports[`SrpInputGrid renders with disabled state 1`] = ` [ { "backgroundColor": "inherit", - "height": 38, + "height": 46, }, { "flex": 1, @@ -578,9 +578,9 @@ exports[`SrpInputGrid renders with empty seed phrase 1`] = ` style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc866", + "borderRadius": 12, "borderWidth": 1, "flexDirection": "row", "height": 0, @@ -598,7 +598,7 @@ exports[`SrpInputGrid renders with empty seed phrase 1`] = ` [ { "backgroundColor": "inherit", - "height": 38, + "height": 46, }, { "flex": 1, @@ -677,13 +677,13 @@ exports[`SrpInputGrid renders with empty seed phrase 1`] = ` { "alignItems": "center", "backgroundColor": "transparent", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "borderColor": "#b7bbc866", + "borderRadius": 12, "borderWidth": 0, "display": "flex", "flex": 1, "flexDirection": "row", - "height": 40, + "height": 48, "opacity": 1, "paddingHorizontal": 0, } @@ -695,7 +695,7 @@ exports[`SrpInputGrid renders with empty seed phrase 1`] = ` [ { "backgroundColor": "inherit", - "height": 38, + "height": 46, }, { "flex": 1, @@ -838,7 +838,7 @@ exports[`SrpInputGrid renders with multiple words 1`] = ` { "alignItems": "center", "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", + "borderColor": "#b7bbc866", "borderRadius": 8, "borderWidth": 1, "color": "#121314", @@ -865,7 +865,7 @@ exports[`SrpInputGrid renders with multiple words 1`] = ` ( onSubmitEditing={() => Keyboard.dismiss()} placeholder="" placeholderTextColor={colors.text.muted} - size={TextFieldSize.Md} style={ isFirstInput ? styles.hiddenInput @@ -453,7 +451,6 @@ const SrpInputGrid = React.forwardRef( }} placeholder={placeholderText} placeholderTextColor={colors.text.alternative} - size={TextFieldSize.Md} style={ isFirstInput ? styles.seedPhraseDefaultInput diff --git a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/index.tsx b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/index.tsx index 4e35bbc2d0f..90c3e9c436a 100644 --- a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/index.tsx +++ b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/index.tsx @@ -3,7 +3,7 @@ import BottomSheet, { BottomSheetRef, } from '../../../../../component-library/components/BottomSheets/BottomSheet'; import { ScrollView } from 'react-native'; -import HeaderCenter from '../../../../../component-library/components-temp/HeaderCenter'; +import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; import { useTheme } from '../../../../../util/theme'; import { useRoute, RouteProp } from '@react-navigation/native'; @@ -125,7 +125,7 @@ const PoolStakingLearnMoreModal = () => { return ( - diff --git a/app/components/UI/Stake/components/StakeButton/StakeButton.styles.ts b/app/components/UI/Stake/components/StakeButton/StakeButton.styles.ts new file mode 100644 index 00000000000..e5cacd05b82 --- /dev/null +++ b/app/components/UI/Stake/components/StakeButton/StakeButton.styles.ts @@ -0,0 +1,18 @@ +import { StyleSheet } from 'react-native'; +import { Theme } from '../../../../../util/theme/models'; + +export const styleSheet = (params: { theme: Theme }) => { + const { theme } = params; + + return StyleSheet.create({ + stakeButton: { + flexDirection: 'row', + backgroundColor: theme.colors.background.muted, + paddingHorizontal: 6, + borderRadius: 5, + marginLeft: 8, + }, + }); +}; + +export default styleSheet; diff --git a/app/components/UI/Stake/components/StakeButton/index.tsx b/app/components/UI/Stake/components/StakeButton/index.tsx index cd45166a9c5..9477de9979c 100644 --- a/app/components/UI/Stake/components/StakeButton/index.tsx +++ b/app/components/UI/Stake/components/StakeButton/index.tsx @@ -1,6 +1,6 @@ import { useNavigation } from '@react-navigation/native'; import React from 'react'; -import { StyleSheet, TouchableOpacity } from 'react-native'; +import { TouchableOpacity } from 'react-native'; import { useSelector } from 'react-redux'; import { WalletViewSelectorsIDs } from '../../../../Views/Wallet/WalletView.testIds'; import { strings } from '../../../../../../locales/i18n'; @@ -41,23 +41,15 @@ import BigNumber from 'bignumber.js'; import { MINIMUM_BALANCE_FOR_EARN_CTA } from '../../../Earn/constants/token'; import useEarnToken from '../../../Earn/hooks/useEarnToken'; import { EarnTokenDetails } from '../../../Earn/types/lending.types'; - -const styles = StyleSheet.create({ - stakeButton: { - flexDirection: 'row', - }, - dot: { - marginLeft: 2, - marginRight: 2, - }, -}); - +import { useStyles } from '../../../../../component-library/hooks'; +import styleSheet from './StakeButton.styles'; interface StakeButtonContentProps { earnToken: EarnTokenDetails; } // TODO: Rename to EarnCta to better describe this component's purpose. const StakeButtonContent = ({ earnToken }: StakeButtonContentProps) => { + const { styles } = useStyles(styleSheet, {}); const navigation = useNavigation(); const { trackEvent, createEventBuilder } = useMetrics(); const chainId = useSelector(selectEvmChainId); @@ -187,10 +179,7 @@ const StakeButtonContent = ({ earnToken }: StakeButtonContentProps) => { testID={WalletViewSelectorsIDs.STAKE_BUTTON} style={styles.stakeButton} > - - {' • '} - - + {renderEarnButtonText()} diff --git a/app/components/UI/TokenDetails/components/MoreTokenActionsMenu.tsx b/app/components/UI/TokenDetails/components/MoreTokenActionsMenu.tsx index f829da01518..8410c454cc5 100644 --- a/app/components/UI/TokenDetails/components/MoreTokenActionsMenu.tsx +++ b/app/components/UI/TokenDetails/components/MoreTokenActionsMenu.tsx @@ -8,20 +8,13 @@ import { IconName, Box } from '@metamask/design-system-react-native'; import ActionListItem from '../../../../component-library/components-temp/ActionListItem'; import { strings } from '../../../../../locales/i18n'; import { useMetrics } from '../../../hooks/useMetrics'; -import { useRampNavigation } from '../../Ramp/hooks/useRampNavigation'; import useBlockExplorer from '../../../hooks/useBlockExplorer'; -import useRampsUnifiedV1Enabled from '../../Ramp/hooks/useRampsUnifiedV1Enabled'; -import { useRampsButtonClickData } from '../../Ramp/hooks/useRampsButtonClickData'; import Routes from '../../../../constants/navigation/Routes'; import Engine from '../../../../core/Engine'; import NotificationManager from '../../../../core/NotificationManager'; import { selectTokenList } from '../../../../selectors/tokenListController'; -import { selectChainId } from '../../../../selectors/networkController'; import { getDecimalChainId } from '../../../../util/networks'; -import { getDetectedGeolocation } from '../../../../reducers/fiatOrders'; import { MetaMetricsEvents } from '../../../../core/Analytics'; -import { trace, TraceName } from '../../../../util/trace'; -import { RampType } from '../../../../reducers/fiatOrders/types'; import { WalletActionsBottomSheetSelectorsIDs } from '../../../Views/WalletActions/WalletActionsBottomSheet.testIds'; import Logger from '../../../../util/Logger'; import { Hex } from '@metamask/utils'; @@ -34,7 +27,7 @@ export interface MoreTokenActionsMenuParams { isBuyable: boolean; isNativeCurrency: boolean; asset: TokenI; - onBuy?: () => void; + onBuy: () => void; onReceive?: () => void; } @@ -65,17 +58,12 @@ const MoreTokenActionsMenu = () => { isBuyable, isNativeCurrency, asset, - onBuy: customOnBuy, + onBuy, onReceive, } = route.params; const { trackEvent, createEventBuilder } = useMetrics(); - const { goToBuy, goToAggregator } = useRampNavigation(); - const rampUnifiedV1Enabled = useRampsUnifiedV1Enabled(); - const rampsButtonClickData = useRampsButtonClickData(); - const rampGeodetectedRegion = useSelector(getDetectedGeolocation); const tokenList = useSelector(selectTokenList); - const chainId = useSelector(selectChainId); const explorer = useBlockExplorer(asset.chainId); const closeBottomSheetAndNavigate = useCallback( @@ -101,99 +89,9 @@ const MoreTokenActionsMenu = () => { [closeBottomSheetAndNavigate, navigation], ); - const getChainIdForAsset = useCallback(() => { - if (asset.chainId) { - if (typeof asset.chainId === 'string' && asset.chainId.startsWith('0x')) { - const parsed = parseInt(asset.chainId, 16); - return isNaN(parsed) ? getDecimalChainId(chainId) : parsed; - } - const parsed = parseInt(asset.chainId, 10); - return isNaN(parsed) ? getDecimalChainId(chainId) : parsed; - } - return getDecimalChainId(chainId); - }, [asset.chainId, chainId]); - - // Fund action handlers (same as FundActionMenu) - const handleBuyUnified = useCallback(() => { - closeBottomSheetAndNavigate(() => { - if (customOnBuy) { - customOnBuy(); - } else { - goToBuy({ assetId: asset.address }); - } - }); - - if (!customOnBuy) { - trackEvent( - createEventBuilder(MetaMetricsEvents.RAMPS_BUTTON_CLICKED) - .addProperties({ - text: 'Buy', - location: 'MoreTokenActionsMenu', - chain_id_destination: getChainIdForAsset(), - ramp_type: 'UNIFIED_BUY', - region: rampGeodetectedRegion, - ramp_routing: rampsButtonClickData.ramp_routing, - is_authenticated: rampsButtonClickData.is_authenticated, - preferred_provider: rampsButtonClickData.preferred_provider, - order_count: rampsButtonClickData.order_count, - }) - .build(), - ); - } - }, [ - closeBottomSheetAndNavigate, - customOnBuy, - goToBuy, - asset.address, - trackEvent, - createEventBuilder, - getChainIdForAsset, - rampGeodetectedRegion, - rampsButtonClickData, - ]); - const handleBuy = useCallback(() => { - closeBottomSheetAndNavigate(() => { - if (customOnBuy) { - customOnBuy(); - } else { - goToAggregator({ assetId: asset.address }); - } - }); - - if (!customOnBuy) { - trackEvent( - createEventBuilder(MetaMetricsEvents.RAMPS_BUTTON_CLICKED) - .addProperties({ - text: 'Buy', - location: 'MoreTokenActionsMenu', - chain_id_destination: getChainIdForAsset(), - ramp_type: 'BUY', - region: rampGeodetectedRegion, - ramp_routing: rampsButtonClickData.ramp_routing, - is_authenticated: rampsButtonClickData.is_authenticated, - preferred_provider: rampsButtonClickData.preferred_provider, - order_count: rampsButtonClickData.order_count, - }) - .build(), - ); - - trace({ - name: TraceName.LoadRampExperience, - tags: { rampType: RampType.BUY }, - }); - } - }, [ - closeBottomSheetAndNavigate, - customOnBuy, - goToAggregator, - asset.address, - trackEvent, - createEventBuilder, - getChainIdForAsset, - rampGeodetectedRegion, - rampsButtonClickData, - ]); + closeBottomSheetAndNavigate(onBuy); + }, [closeBottomSheetAndNavigate, onBuy]); const handleReceive = useCallback(() => { closeBottomSheetAndNavigate(() => { @@ -251,7 +149,7 @@ const MoreTokenActionsMenu = () => { token_standard: 'ERC20', asset_type: 'token', tokens: [`${tokenSymbol} - ${asset.address}`], - chain_id: getDecimalChainId(chainId), + chain_id: getDecimalChainId(asset.chainId), }) .build(), ); @@ -270,7 +168,6 @@ const MoreTokenActionsMenu = () => { tokenList, trackEvent, createEventBuilder, - chainId, ]); const actionConfigs: ActionConfig[] = useMemo(() => { @@ -297,7 +194,7 @@ const MoreTokenActionsMenu = () => { iconName: IconName.AttachMoney, testID: WalletActionsBottomSheetSelectorsIDs.BUY_BUTTON, isVisible: true, - onPress: rampUnifiedV1Enabled ? handleBuyUnified : handleBuy, + onPress: handleBuy, }); } @@ -334,10 +231,8 @@ const MoreTokenActionsMenu = () => { asset.chainId, asset.symbol, explorer, - rampUnifiedV1Enabled, onReceive, handleReceive, - handleBuyUnified, handleBuy, handleViewOnBlockExplorer, handleRemoveToken, diff --git a/app/components/UI/TokenDetails/components/TokenDetailsActions.tsx b/app/components/UI/TokenDetails/components/TokenDetailsActions.tsx index 607ef1ee353..b7ee866223c 100644 --- a/app/components/UI/TokenDetails/components/TokenDetailsActions.tsx +++ b/app/components/UI/TokenDetails/components/TokenDetailsActions.tsx @@ -10,13 +10,6 @@ import { TokenOverviewSelectorsIDs } from '../../AssetOverview/TokenOverview.tes import { useSelector } from 'react-redux'; import { selectCanSignTransactions } from '../../../../selectors/accountsController'; import Routes from '../../../../constants/navigation/Routes'; -import { useMetrics } from '../../../hooks/useMetrics'; -import { - trackActionButtonClick, - ActionButtonType, - ActionLocation, - ActionPosition, -} from '../../../../util/analytics/actionButtonTracking'; import { TokenI } from '../../Tokens/types'; // Height of MainActionButton: paddingVertical (16 * 2) + Icon (24px) + label marginTop (2) + label lineHeight (~16) @@ -45,7 +38,7 @@ export interface TokenDetailsActionsProps { isBuyable: boolean; isNativeCurrency: boolean; token: TokenI; - onBuy?: () => void; + onBuy: () => void; onLong?: () => void; onShort?: () => void; onSend: () => void; @@ -93,7 +86,6 @@ export const TokenDetailsActions: React.FC = ({ const canSignTransactions = useSelector(selectCanSignTransactions); const navigation = useNavigation(); const { navigate } = navigation; - const { trackEvent, createEventBuilder } = useMetrics(); // Prevent rapid navigation clicks - locks all buttons during navigation const navigationLockRef = useRef(false); @@ -121,33 +113,8 @@ export const TokenDetailsActions: React.FC = ({ }, []); const handleBuyPress = useCallback(() => { - withNavigationLock(() => { - trackActionButtonClick(trackEvent, createEventBuilder, { - action_name: ActionButtonType.BUY, - action_position: ActionPosition.FIRST_POSITION, - button_label: strings('asset_overview.cash_buy_button'), - location: ActionLocation.HOME, - }); - - navigate(Routes.MODAL.ROOT_MODAL_FLOW, { - screen: Routes.MODAL.FUND_ACTION_MENU, - params: { - onBuy, - asset: { - address: token.address, - chainId: token.chainId, - }, - }, - }); - }); - }, [ - withNavigationLock, - trackEvent, - createEventBuilder, - navigate, - onBuy, - token, - ]); + withNavigationLock(onBuy); + }, [withNavigationLock, onBuy]); const handleLongPress = useCallback(() => { withNavigationLock(() => { @@ -213,7 +180,7 @@ export const TokenDetailsActions: React.FC = ({ iconName: IconName.AttachMoney, label: strings('asset_overview.cash_buy_button'), onPress: handleBuyPress, - isDisabled: !onBuy, + isDisabled: false, testID: TokenOverviewSelectorsIDs.BUY_BUTTON, }); } @@ -322,7 +289,6 @@ export const TokenDetailsActions: React.FC = ({ handleReceivePress, handleMorePress, canSignTransactions, - onBuy, onLong, onShort, ]); diff --git a/app/components/UI/TokenDetails/hooks/useTokenActions.test.ts b/app/components/UI/TokenDetails/hooks/useTokenActions.test.ts index 295fe523e69..19e769ef373 100644 --- a/app/components/UI/TokenDetails/hooks/useTokenActions.test.ts +++ b/app/components/UI/TokenDetails/hooks/useTokenActions.test.ts @@ -15,6 +15,8 @@ import { ActionLocation, } from '../../../../util/analytics/actionButtonTracking'; import Routes from '../../../../constants/navigation/Routes'; +import { isCaipAssetType } from '@metamask/utils'; +import { formatAddressToAssetId } from '@metamask/bridge-controller'; jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), @@ -123,6 +125,19 @@ jest.mock('../../Bridge/utils/tokenUtils', () => ({ getNativeSourceToken: jest.fn(), })); +jest.mock('@metamask/utils', () => ({ + ...jest.requireActual('@metamask/utils'), + isCaipAssetType: jest.fn(), +})); + +jest.mock('@metamask/bridge-controller', () => ({ + ...jest.requireActual('@metamask/bridge-controller'), + formatAddressToAssetId: jest.fn(), +})); + +const mockIsCaipAssetType = jest.mocked(isCaipAssetType); +const mockFormatAddressToAssetId = jest.mocked(formatAddressToAssetId); + jest.mock('../../../../core/Engine', () => ({ context: { NetworkController: { @@ -373,6 +388,21 @@ describe('useTokenActions', () => { }); describe('handleBuyPress', () => { + beforeEach(() => { + // Default mock behavior for assetId generation + mockIsCaipAssetType.mockReturnValue(false); + mockFormatAddressToAssetId.mockImplementation( + (address: string, chainId: string | number) => { + // Simulate the real behavior for EVM tokens + const numericChainId = + typeof chainId === 'string' ? parseInt(chainId, 16) : chainId; + const checksumAddress = + address.slice(0, 2) + address.slice(2).toUpperCase(); + return `eip155:${numericChainId}/erc20:${checksumAddress}`; + }, + ); + }); + it('routes to on-ramp when no eligible tokens exist', () => { // Empty user assets (no tokens with balance) - uses default from setupDefaultMocks const { result } = renderHook(() => @@ -388,6 +418,206 @@ describe('useTokenActions', () => { expect(mockGoToSwaps).not.toHaveBeenCalled(); }); + describe('assetId generation for on-ramp', () => { + it('uses token.address directly for non-EVM tokens with CAIP address (Solana)', () => { + // Real Solana token structure - address is already a CAIP asset type + const solanaToken = { + address: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:AUSD1jCcCyPLybk1YnvPWsHQSrZ46dxwoMniN4N2UEB9', + aggregators: [], + decimals: 6, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token/AUSD1jCcCyPLybk1YnvPWsHQSrZ46dxwoMniN4N2UEB9.png', + name: 'AUSD', + symbol: 'AUSD', + balance: '0', + balanceFiat: '$0.00', + isETH: false, + isStaked: false, + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + isNative: false, + ticker: 'AUSD', + accountType: 'solana:data-account', + } as unknown as TokenI; + + mockIsCaipAssetType.mockReturnValue(true); + + const { result } = renderHook(() => + useTokenActions({ + token: solanaToken, + networkName: 'Solana', + }), + ); + + result.current.handleBuyPress(); + + expect(mockIsCaipAssetType).toHaveBeenCalledWith(solanaToken.address); + expect(mockFormatAddressToAssetId).not.toHaveBeenCalled(); + expect(mockGoToBuy).toHaveBeenCalledWith({ + assetId: solanaToken.address, + }); + }); + + it('uses token.address directly for trending non-EVM tokens with CAIP address', () => { + // Real trending Solana token structure + const trendingSolanaToken = { + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + address: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:4j1B6dZn9s4nmf8yZhResvSrTA3nmMhDnfNYY2Q5N7c1', + decimals: 6, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token/4j1B6dZn9s4nmf8yZhResvSrTA3nmMhDnfNYY2Q5N7c1.png', + pricePercentChange1d: 358.639, + isNative: false, + isETH: false, + isFromTrending: true, + } as unknown as TokenI; + + mockIsCaipAssetType.mockReturnValue(true); + + const { result } = renderHook(() => + useTokenActions({ + token: trendingSolanaToken, + networkName: 'Solana', + }), + ); + + result.current.handleBuyPress(); + + expect(mockIsCaipAssetType).toHaveBeenCalledWith( + trendingSolanaToken.address, + ); + expect(mockFormatAddressToAssetId).not.toHaveBeenCalled(); + expect(mockGoToBuy).toHaveBeenCalledWith({ + assetId: trendingSolanaToken.address, + }); + }); + + it('uses formatAddressToAssetId for EVM tokens with hex address', () => { + // Real EVM token structure - address is hex, chainId is hex + const evmToken = { + address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + aggregators: [], + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0x6b175474e89094c44da98b954eedeac495271d0f.png', + name: 'Dai Stablecoin', + symbol: 'DAI', + balance: '0', + balanceFiat: '$0.00', + isETH: false, + isStaked: false, + chainId: '0x1', + isNative: false, + ticker: 'DAI', + accountType: 'eip155:eoa', + } as unknown as TokenI; + + const expectedAssetId = + 'eip155:1/erc20:0x6B175474E89094C44Da98b954EedeAC495271d0F'; + mockIsCaipAssetType.mockReturnValue(false); + mockFormatAddressToAssetId.mockReturnValue(expectedAssetId); + + const { result } = renderHook(() => + useTokenActions({ + token: evmToken, + networkName: 'Ethereum Mainnet', + }), + ); + + result.current.handleBuyPress(); + + expect(mockIsCaipAssetType).toHaveBeenCalledWith(evmToken.address); + expect(mockFormatAddressToAssetId).toHaveBeenCalledWith( + evmToken.address, + evmToken.chainId, + ); + expect(mockGoToBuy).toHaveBeenCalledWith({ + assetId: expectedAssetId, + }); + }); + + it('uses formatAddressToAssetId for trending EVM tokens', () => { + // Real trending EVM token structure + const trendingEvmToken = { + chainId: '0x2105', + address: '0x852df602530532fb356adf25fbf0f6511b764b07', + symbol: 'Dave', + name: 'Dave the Minion', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/8453/erc20/0x852df602530532fb356adf25fbf0f6511b764b07.png', + pricePercentChange1d: 3203.891, + isNative: false, + isETH: false, + isFromTrending: true, + } as unknown as TokenI; + + const expectedAssetId = + 'eip155:8453/erc20:0x852df602530532fb356adf25fbf0f6511b764b07'; + mockIsCaipAssetType.mockReturnValue(false); + mockFormatAddressToAssetId.mockReturnValue(expectedAssetId); + + const { result } = renderHook(() => + useTokenActions({ + token: trendingEvmToken, + networkName: 'Base', + }), + ); + + result.current.handleBuyPress(); + + expect(mockIsCaipAssetType).toHaveBeenCalledWith( + trendingEvmToken.address, + ); + expect(mockFormatAddressToAssetId).toHaveBeenCalledWith( + trendingEvmToken.address, + trendingEvmToken.chainId, + ); + expect(mockGoToBuy).toHaveBeenCalledWith({ + assetId: expectedAssetId, + }); + }); + + it('passes undefined assetId when formatAddressToAssetId throws an error', () => { + mockIsCaipAssetType.mockReturnValue(false); + mockFormatAddressToAssetId.mockImplementation(() => { + throw new Error('Invalid address format'); + }); + + const { result } = renderHook(() => + useTokenActions({ + token: defaultToken, + networkName: 'Ethereum Mainnet', + }), + ); + + result.current.handleBuyPress(); + + expect(mockGoToBuy).toHaveBeenCalledWith({ + assetId: undefined, + }); + }); + + it('passes undefined assetId when formatAddressToAssetId returns null', () => { + mockIsCaipAssetType.mockReturnValue(false); + mockFormatAddressToAssetId.mockReturnValue(undefined); + + const { result } = renderHook(() => + useTokenActions({ + token: defaultToken, + networkName: 'Ethereum Mainnet', + }), + ); + + result.current.handleBuyPress(); + + expect(mockGoToBuy).toHaveBeenCalledWith({ + assetId: undefined, + }); + }); + }); + it('calls goToSwaps with source and dest tokens when user has eligible tokens on same chain', () => { // Override selectAssetsBySelectedAccountGroup with tokens that have balance selectorMocks.mockSelectAssetsBySelectedAccountGroup.mockReturnValue({ diff --git a/app/components/UI/TokenDetails/hooks/useTokenActions.ts b/app/components/UI/TokenDetails/hooks/useTokenActions.ts index 01ec20b118f..c64461531c9 100644 --- a/app/components/UI/TokenDetails/hooks/useTokenActions.ts +++ b/app/components/UI/TokenDetails/hooks/useTokenActions.ts @@ -35,6 +35,7 @@ import { } from '../../Bridge/utils/tokenUtils'; import { useSendNonEvmAsset } from '../../../hooks/useSendNonEvmAsset'; import { + formatAddressToAssetId, formatChainIdToCaip, isNativeAddress, } from '@metamask/bridge-controller'; @@ -391,13 +392,33 @@ export const useTokenActions = ({ const handleBuyPress = useCallback(() => { // If user has no eligible tokens to swap with, route to on-ramp if (!buySourceToken) { - goToBuy(); + let assetId: string | undefined; + + try { + if (isCaipAssetType(token.address)) { + assetId = token.address; + } else if (token.chainId) { + assetId = + formatAddressToAssetId(token.address, token.chainId) ?? undefined; + } + } catch { + assetId = undefined; + } + + goToBuy({ assetId }); return; } if (!goToSwaps) return; goToSwaps(buySourceToken, currentTokenAsBridgeToken); - }, [goToSwaps, goToBuy, buySourceToken, currentTokenAsBridgeToken]); + }, [ + goToSwaps, + goToBuy, + buySourceToken, + currentTokenAsBridgeToken, + token.address, + token.chainId, + ]); // Sell: current token as source, let swap UI compute default dest const handleSellPress = useCallback(() => { diff --git a/app/components/UI/Tokens/TokenList/TokenList.test.tsx b/app/components/UI/Tokens/TokenList/TokenList.test.tsx index bb3c4431508..e37ce837840 100644 --- a/app/components/UI/Tokens/TokenList/TokenList.test.tsx +++ b/app/components/UI/Tokens/TokenList/TokenList.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { render, fireEvent } from '@testing-library/react-native'; +import { render, fireEvent, act } from '@testing-library/react-native'; +import { DeviceEventEmitter } from 'react-native'; import { Provider, useSelector } from 'react-redux'; import configureMockStore from 'redux-mock-store'; import { TokenList } from './TokenList'; @@ -7,6 +8,7 @@ import { useNavigation } from '@react-navigation/native'; import { WalletViewSelectorsIDs } from '../../../Views/Wallet/WalletView.testIds'; import { useMetrics } from '../../../hooks/useMetrics'; import { MetricsEventBuilder } from '../../../../core/Analytics/MetricsEventBuilder'; +import { SCROLL_TO_TOKEN_EVENT } from '../constants'; // Mock external dependencies jest.mock('@react-navigation/native', () => ({ @@ -122,6 +124,7 @@ jest.mock('@metamask/design-system-react-native', () => ({ })); // Mock FlashList +const mockScrollToIndex = jest.fn(); jest.mock('@shopify/flash-list', () => { const React = jest.requireActual('react'); const { FlatList } = jest.requireActual('react-native'); @@ -130,6 +133,7 @@ jest.mock('@shopify/flash-list', () => { (props: Record, ref: React.Ref) => { React.useImperativeHandle(ref, () => ({ recomputeViewableItems: jest.fn(), + scrollToIndex: mockScrollToIndex, })); return React.createElement(FlatList, { ...props, ref }); }, @@ -482,4 +486,216 @@ describe('TokenList', () => { expect(queryByTestId('token-item-0x456')).toBeOnTheScreen(); }); }); + + describe('Scroll to Token Event', () => { + beforeEach(() => { + mockScrollToIndex.mockClear(); + // Reset selector mocks + mockUseSelector.mockReset(); + }); + + afterEach(() => { + // Clean up any event listeners + DeviceEventEmitter.removeAllListeners(SCROLL_TO_TOKEN_EVENT); + DeviceEventEmitter.removeAllListeners('scrollToTokenIndex'); + }); + + it('scrolls to token using FlashList scrollToIndex when token is found and using FlashList mode', () => { + // Set up for FlashList mode (homepage redesign disabled) + mockUseSelector.mockImplementation((selector) => { + if (selector.toString().includes('selectHomepageRedesignV1Enabled')) { + return false; + } + return selector({}); + }); + + renderComponent({ isFullView: true }); + + // Emit scroll-to-token event + act(() => { + DeviceEventEmitter.emit(SCROLL_TO_TOKEN_EVENT, { + address: '0x123', + chainId: '0x1', + }); + }); + + expect(mockScrollToIndex).toHaveBeenCalledWith({ + index: 0, + animated: true, + viewPosition: 0.5, + }); + }); + + it('scrolls to correct index when token is not first in list', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector.toString().includes('selectHomepageRedesignV1Enabled')) { + return false; + } + return selector({}); + }); + + renderComponent({ isFullView: true }); + + act(() => { + DeviceEventEmitter.emit(SCROLL_TO_TOKEN_EVENT, { + address: '0x456', + chainId: '0x1', + }); + }); + + expect(mockScrollToIndex).toHaveBeenCalledWith({ + index: 1, + animated: true, + viewPosition: 0.5, + }); + }); + + it('does not scroll when token is not found in the list', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector.toString().includes('selectHomepageRedesignV1Enabled')) { + return false; + } + return selector({}); + }); + + renderComponent({ isFullView: true }); + + act(() => { + DeviceEventEmitter.emit(SCROLL_TO_TOKEN_EVENT, { + address: '0xnonexistent', + chainId: '0x1', + }); + }); + + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); + + it('does not scroll when chainId does not match', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector.toString().includes('selectHomepageRedesignV1Enabled')) { + return false; + } + return selector({}); + }); + + renderComponent({ isFullView: true }); + + act(() => { + DeviceEventEmitter.emit(SCROLL_TO_TOKEN_EVENT, { + address: '0x123', + chainId: '0x5', // Different chainId + }); + }); + + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); + + it('emits scrollToTokenIndex event in .map() mode (homepage redesign enabled, not full view)', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector.toString().includes('selectHomepageRedesignV1Enabled')) { + return true; + } + return selector({}); + }); + + const scrollToTokenIndexHandler = jest.fn(); + DeviceEventEmitter.addListener( + 'scrollToTokenIndex', + scrollToTokenIndexHandler, + ); + + renderComponent({ isFullView: false }); + + act(() => { + DeviceEventEmitter.emit(SCROLL_TO_TOKEN_EVENT, { + address: '0x123', + chainId: '0x1', + }); + }); + + expect(scrollToTokenIndexHandler).toHaveBeenCalledWith({ + index: 0, + offset: 0, // 0 * 72 (TOKEN_ROW_HEIGHT) + }); + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); + + it('calculates correct offset based on token index in .map() mode', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector.toString().includes('selectHomepageRedesignV1Enabled')) { + return true; + } + return selector({}); + }); + + const scrollToTokenIndexHandler = jest.fn(); + DeviceEventEmitter.addListener( + 'scrollToTokenIndex', + scrollToTokenIndexHandler, + ); + + renderComponent({ isFullView: false }); + + act(() => { + DeviceEventEmitter.emit(SCROLL_TO_TOKEN_EVENT, { + address: '0x456', + chainId: '0x1', + }); + }); + + expect(scrollToTokenIndexHandler).toHaveBeenCalledWith({ + index: 1, + offset: 72, // 1 * 72 (TOKEN_ROW_HEIGHT) + }); + }); + + it('matches token address case-insensitively', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector.toString().includes('selectHomepageRedesignV1Enabled')) { + return false; + } + return selector({}); + }); + + renderComponent({ isFullView: true }); + + act(() => { + DeviceEventEmitter.emit(SCROLL_TO_TOKEN_EVENT, { + address: '0X123', // Uppercase + chainId: '0x1', + }); + }); + + expect(mockScrollToIndex).toHaveBeenCalledWith({ + index: 0, + animated: true, + viewPosition: 0.5, + }); + }); + + it('cleans up event listener on unmount', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector.toString().includes('selectHomepageRedesignV1Enabled')) { + return false; + } + return selector({}); + }); + + const { unmount } = renderComponent({ isFullView: true }); + + // Unmount the component + unmount(); + + // Emit event after unmount + act(() => { + DeviceEventEmitter.emit(SCROLL_TO_TOKEN_EVENT, { + address: '0x123', + chainId: '0x1', + }); + }); + + // Should not scroll because listener was removed + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); + }); }); diff --git a/app/components/UI/Tokens/TokenList/TokenList.tsx b/app/components/UI/Tokens/TokenList/TokenList.tsx index e637ef6b821..acf7ff680d7 100644 --- a/app/components/UI/Tokens/TokenList/TokenList.tsx +++ b/app/components/UI/Tokens/TokenList/TokenList.tsx @@ -1,5 +1,11 @@ -import React, { useCallback, useLayoutEffect, useRef, useMemo } from 'react'; -import { RefreshControl } from 'react-native'; +import React, { + useCallback, + useLayoutEffect, + useRef, + useMemo, + useEffect, +} from 'react'; +import { DeviceEventEmitter, RefreshControl } from 'react-native'; import { FlashList, FlashListRef } from '@shopify/flash-list'; import { useSelector } from 'react-redux'; import { useTheme } from '../../../../util/theme'; @@ -23,6 +29,7 @@ import { import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { MetaMetricsEvents, useMetrics } from '../../../hooks/useMetrics'; import { useMusdCtaVisibility } from '../../Earn/hooks/useMusdCtaVisibility'; +import { SCROLL_TO_TOKEN_EVENT } from '../constants'; export interface FlashListAssetKey { address: string; @@ -72,15 +79,6 @@ const TokenListComponent = ({ listRef.current?.recomputeViewableItems(); }, [isTokenNetworkFilterEqualCurrentNetwork]); - const handleViewAllTokens = useCallback(() => { - trackEvent( - createEventBuilder(MetaMetricsEvents.VIEW_ALL_ASSETS_CLICKED) - .addProperties({ asset_type: 'Token' }) - .build(), - ); - navigation.navigate(Routes.WALLET.TOKENS_FULL_VIEW); - }, [navigation, trackEvent, createEventBuilder]); - // Apply maxItems limit if specified const displayTokenKeys = useMemo( () => (tokenKeys || []).slice(0, maxItems || undefined), @@ -93,6 +91,57 @@ const TokenListComponent = ({ [maxItems, tokenKeys], ); + // Listen for scroll-to-token events (e.g., after claiming mUSD rewards) + useEffect(() => { + const subscription = DeviceEventEmitter.addListener( + SCROLL_TO_TOKEN_EVENT, + ({ address, chainId }: { address: string; chainId: string }) => { + // Find the index of the token in the display list + const tokenIndex = displayTokenKeys.findIndex( + (item) => + item.address?.toLowerCase() === address?.toLowerCase() && + item.chainId === chainId, + ); + + if (tokenIndex === -1) { + return; + } + + // For FlashList mode, use scrollToIndex + if (!isHomepageRedesignV1Enabled || isFullView) { + if (listRef.current) { + listRef.current.scrollToIndex({ + index: tokenIndex, + animated: true, + viewPosition: 0.5, // Center the item in the viewport + }); + } + } else { + // For .map() mode, emit event with index for parent ScrollView to handle + // Approximate token row height is ~72px + const TOKEN_ROW_HEIGHT = 72; + DeviceEventEmitter.emit('scrollToTokenIndex', { + index: tokenIndex, + offset: tokenIndex * TOKEN_ROW_HEIGHT, + }); + } + }, + ); + + return () => { + subscription.remove(); + }; + }, [displayTokenKeys, isHomepageRedesignV1Enabled, isFullView]); + + const handleViewAllTokens = useCallback(() => { + trackEvent( + createEventBuilder(MetaMetricsEvents.VIEW_ALL_ASSETS_CLICKED) + .addProperties({ asset_type: 'Token' }) + .build(), + ); + navigation.navigate(Routes.WALLET.TOKENS_FULL_VIEW); + }, [navigation, trackEvent, createEventBuilder]); + const renderTokenListItem = useCallback( ({ item }: { item: FlashListAssetKey }) => ( { ); mockUseMusdConversionTokens.mockReturnValue({ isConversionToken: jest.fn().mockReturnValue(false), + hasConvertibleTokensByChainId: jest.fn().mockReturnValue(false), filterAllowedTokens: jest.fn(), isMusdSupportedOnChain: jest.fn().mockReturnValue(true), tokens: [], @@ -1082,4 +1083,76 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { expect(queryByText('+1.23%')).toBeNull(); }); }); + + describe('mUSD Token Long Press', () => { + const musdAddress = '0xaca92e438df0b2401ff60da7e4337b687a2435da'; + const musdAsset = { + ...defaultAsset, + address: musdAddress, + symbol: 'mUSD', + name: 'MetaMask USD', + isNative: false, + }; + + const musdAssetKey: FlashListAssetKey = { + address: musdAddress, + chainId: '0x1', + isStaked: false, + }; + + it('does not call showRemoveMenu on long press for mUSD token', () => { + prepareMocks({ + asset: musdAsset, + }); + + const mockShowRemoveMenu = jest.fn(); + const { getByText } = renderWithProvider( + , + ); + + const tokenElement = getByText('MetaMask USD'); + fireEvent(tokenElement, 'longPress'); + + expect(mockShowRemoveMenu).not.toHaveBeenCalled(); + }); + + it('calls showRemoveMenu on long press for non-mUSD token', () => { + prepareMocks({ + asset: defaultAsset, + }); + + const mockShowRemoveMenu = jest.fn(); + const assetKey: FlashListAssetKey = { + address: '0x456', + chainId: '0x1', + isStaked: false, + }; + + const { getByText } = renderWithProvider( + , + ); + + const tokenElement = getByText('Test Token'); + fireEvent(tokenElement, 'longPress'); + + expect(mockShowRemoveMenu).toHaveBeenCalledWith( + expect.objectContaining({ + address: '0x456', + symbol: 'TEST', + }), + ); + }); + }); }); diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx index e9cab29dc67..57147128855 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx @@ -50,7 +50,7 @@ import { toHex } from '@metamask/controller-utils'; import Logger from '../../../../../util/Logger'; import { useNetworkName } from '../../../../Views/confirmations/hooks/useNetworkName'; import { MUSD_EVENTS_CONSTANTS } from '../../../Earn/constants/events'; -import { MUSD_CONVERSION_APY } from '../../../Earn/constants/musd'; +import { MUSD_CONVERSION_APY, isMusdToken } from '../../../Earn/constants/musd'; import { useMerklRewards, isEligibleForMerklRewards, @@ -379,7 +379,9 @@ export const TokenListItem = React.memo( return ( - { TransactionType.predictWithdraw, strings('transactions.tx_review_predict_withdraw'), ], + [ + TransactionType.musdConversion, + strings('transactions.tx_review_musd_conversion'), + ], ])('if %s', async (transactionType, title) => { const args = { tx: { diff --git a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx index 6aa7c2934fb..e8e6c7a07b7 100644 --- a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx +++ b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx @@ -3,7 +3,7 @@ import { StyleSheet, ScrollView } from 'react-native'; import BottomSheet, { BottomSheetRef, } from '../../../../../component-library/components/BottomSheets/BottomSheet'; -import HeaderCenter from '../../../../../component-library/components-temp/HeaderCenter'; +import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; import Icon, { IconName, IconSize, @@ -118,7 +118,7 @@ const TrendingTokenNetworkBottomSheet: React.FC< onClose={handleSheetClose} testID="trending-token-network-bottom-sheet" > - - - = ({ } return ( - { animatedStyle, ]} > - @@ -482,7 +482,7 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { onOpen={onBottomSheetOpen} keyboardAvoidingViewEnabled={keyboardAvoidingViewEnabled} > - - { sheetRef.current?.onCloseBottomSheet(() => { diff --git a/app/components/Views/AssetDetails/AssetsDetails.test.tsx b/app/components/Views/AssetDetails/AssetsDetails.test.tsx index 5a68323bb9d..dde1bc6913d 100644 --- a/app/components/Views/AssetDetails/AssetsDetails.test.tsx +++ b/app/components/Views/AssetDetails/AssetsDetails.test.tsx @@ -316,6 +316,29 @@ describe('AssetDetails', () => { runAfterInteractionsSpy.mockRestore(); }); + it('hides the Hide token button for mUSD tokens', () => { + const musdAddress = '0xaca92e438df0b2401ff60da7e4337b687a2435da'; + + const { queryByText } = render( + + + , + ); + + expect(queryByText('Hide token')).toBeNull(); + }); + it('renders warning banner if balance is undefined', () => { const mockEmptyState = { ...initialState, diff --git a/app/components/Views/AssetDetails/index.tsx b/app/components/Views/AssetDetails/index.tsx index efa4f29e05b..3a26f316391 100644 --- a/app/components/Views/AssetDetails/index.tsx +++ b/app/components/Views/AssetDetails/index.tsx @@ -58,6 +58,7 @@ import { Colors } from '../../../util/theme/models'; import { Hex } from '@metamask/utils'; import { selectLastSelectedEvmAccount } from '../../../selectors/accountsController'; import { TokenI } from '../../UI/Tokens/types'; +import { isMusdToken } from '../../UI/Earn/constants/musd'; import { areAddressesEqual } from '../../../util/address'; // Perps Discovery Banner imports import { selectPerpsEnabledFlag } from '../../UI/Perps'; @@ -465,7 +466,7 @@ const AssetDetails = (props: InnerProps) => { {renderSectionDescription(aggregators.join(', '))} )} - {renderHideButton()} + {!isMusdToken(address) && renderHideButton()} ); diff --git a/app/components/Views/AssetOptions/AssetOptions.test.tsx b/app/components/Views/AssetOptions/AssetOptions.test.tsx index 8dbfbceec88..fd065bfbbdf 100644 --- a/app/components/Views/AssetOptions/AssetOptions.test.tsx +++ b/app/components/Views/AssetOptions/AssetOptions.test.tsx @@ -625,6 +625,40 @@ describe('AssetOptions Component', () => { expect(queryByText('Remove token')).not.toBeOnTheScreen(); }); + + it('hides Remove token option for mUSD token', () => { + const musdAddress = '0xaca92e438df0b2401ff60da7e4337b687a2435da'; + + (useSelector as jest.Mock).mockImplementation((selector) => { + if (selector === selectAssetsBySelectedAccountGroup) + return { + '0x1': [ + { + assetId: musdAddress, + chainId: '0x1', + }, + ], + }; + if (selector.name === 'selectEvmChainId') return '0x1'; + if (selector.name === 'selectTokenList') return {}; + return {}; + }); + + const { queryByText } = render( + , + ); + + expect(queryByText('Remove token')).not.toBeOnTheScreen(); + }); }); describe('Token removal', () => { diff --git a/app/components/Views/AssetOptions/AssetOptions.tsx b/app/components/Views/AssetOptions/AssetOptions.tsx index 1ad3c3487a5..47727c52a55 100644 --- a/app/components/Views/AssetOptions/AssetOptions.tsx +++ b/app/components/Views/AssetOptions/AssetOptions.tsx @@ -36,6 +36,7 @@ import BottomSheet, { BottomSheetRef, } from '../../../component-library/components/BottomSheets/BottomSheet'; import BottomSheetHeader from '../../../component-library/components/BottomSheets/BottomSheetHeader'; +import { isMusdToken } from '../../UI/Earn/constants/musd'; // Wrapped SOL token address on Solana const WRAPPED_SOL_ADDRESS = 'So11111111111111111111111111111111111111111'; @@ -308,6 +309,7 @@ const AssetOptions = (props: Props) => { icon: IconName.DocumentCode, }); !isNativeToken && + !isMusdToken(address) && tokenExistsInState && options.push({ label: strings('asset_details.options.remove_token'), diff --git a/app/components/Views/ChoosePassword/__snapshots__/index.test.tsx.snap b/app/components/Views/ChoosePassword/__snapshots__/index.test.tsx.snap index cfb63e79d92..f3b257c5ec4 100644 --- a/app/components/Views/ChoosePassword/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/ChoosePassword/__snapshots__/index.test.tsx.snap @@ -172,9 +172,9 @@ exports[`ChoosePassword render matches snapshot 1`] = ` style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#4459ff", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc8", + "borderRadius": 12, "borderWidth": 1, "flexDirection": "row", "height": 48, @@ -197,6 +197,8 @@ exports[`ChoosePassword render matches snapshot 1`] = ` autoFocus={true} editable={true} keyboardAppearance="light" + multiline={false} + numberOfLines={1} onBlur={[Function]} onChangeText={[Function]} onFocus={[Function]} @@ -228,7 +230,7 @@ exports[`ChoosePassword render matches snapshot 1`] = ` { returnKeyType="next" autoCapitalize="none" keyboardAppearance={themeAppearance} - size={TextFieldSize.Lg} isError={isPasswordTooShort} style={isPasswordTooShort ? styles.errorBorder : undefined} endAccessory={ @@ -828,7 +826,6 @@ const ChoosePassword = () => { returnKeyType={'done'} autoCapitalize="none" keyboardAppearance={themeAppearance} - size={TextFieldSize.Lg} endAccessory={ toggleShowPassword(1)}> setIsPasswordFieldFocused(true)} @@ -714,7 +712,6 @@ const ImportFromSecretRecoveryPhrase = ({ = ({ saveOnboardingEvent }) => { { testID={ManualBackUpStepsSelectorsIDs.CONFIRM_PASSWORD_INPUT} keyboardAppearance={themeAppearance} autoCapitalize="none" - size={TextFieldSize.Lg} autoFocus /> {warningIncorrectPassword && ( diff --git a/app/components/Views/MultichainAccounts/AddressList/AddressList.tsx b/app/components/Views/MultichainAccounts/AddressList/AddressList.tsx index 6544fe03e36..ce956323729 100644 --- a/app/components/Views/MultichainAccounts/AddressList/AddressList.tsx +++ b/app/components/Views/MultichainAccounts/AddressList/AddressList.tsx @@ -21,7 +21,7 @@ import Routes from '../../../../constants/navigation/Routes'; import styleSheet from './styles'; import type { AddressListProps, AddressItem } from './types'; import ClipboardManager from '../../../../core/ClipboardManager'; -import getHeaderCenterNavbarOptions from '../../../../component-library/components-temp/HeaderCenter/getHeaderCenterNavbarOptions'; +import getHeaderCompactStandardNavbarOptions from '../../../../component-library/components-temp/HeaderCompactStandard/getHeaderCompactStandardNavbarOptions'; import { ToastContext } from '../../../../component-library/components/Toast'; import { strings } from '../../../../../locales/i18n'; import { EVENT_NAME } from '../../../../core/Analytics/MetaMetrics.events'; @@ -104,7 +104,7 @@ export const AddressList = () => { useLayoutEffect(() => { if (title) { navigation.setOptions({ - ...getHeaderCenterNavbarOptions({ + ...getHeaderCompactStandardNavbarOptions({ title, onBack: () => navigation.goBack(), backButtonProps: { testID: AddressListIds.GO_BACK }, diff --git a/app/components/Views/MultichainAccounts/sheets/ShareAddressQR/ShareAddressQR.tsx b/app/components/Views/MultichainAccounts/sheets/ShareAddressQR/ShareAddressQR.tsx index 86882c1b185..488a731003b 100644 --- a/app/components/Views/MultichainAccounts/sheets/ShareAddressQR/ShareAddressQR.tsx +++ b/app/components/Views/MultichainAccounts/sheets/ShareAddressQR/ShareAddressQR.tsx @@ -4,7 +4,7 @@ import { AccountGroupId } from '@metamask/account-api'; import BottomSheet, { BottomSheetRef, } from '../../../../../component-library/components/BottomSheets/BottomSheet'; -import HeaderCenter from '../../../../../component-library/components-temp/HeaderCenter'; +import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; import { strings } from '../../../../../../locales/i18n'; import { ParamListBase, @@ -71,7 +71,7 @@ export const ShareAddressQR = () => { return ( - = ({ {strings('login.password')} - navigation.goBack()} includesTopInset diff --git a/app/components/Views/Settings/RegionSelector/__snapshots__/RegionSelector.test.tsx.snap b/app/components/Views/Settings/RegionSelector/__snapshots__/RegionSelector.test.tsx.snap index 060e57049a8..888049ff759 100644 --- a/app/components/Views/Settings/RegionSelector/__snapshots__/RegionSelector.test.tsx.snap +++ b/app/components/Views/Settings/RegionSelector/__snapshots__/RegionSelector.test.tsx.snap @@ -383,12 +383,12 @@ exports[`RegionSelector clears search and scrolls to top when clear button is pr style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc866", + "borderRadius": 24, "borderWidth": 1, "flexDirection": "row", - "height": 40, + "height": 48, "opacity": 1, "paddingHorizontal": 16, } @@ -398,23 +398,23 @@ exports[`RegionSelector clears search and scrolls to top when clear button is pr @@ -3098,12 +3104,12 @@ exports[`RegionSelector displays grouped search results showing country and matc style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc866", + "borderRadius": 24, "borderWidth": 1, "flexDirection": "row", - "height": 40, + "height": 48, "opacity": 1, "paddingHorizontal": 16, } @@ -3113,23 +3119,23 @@ exports[`RegionSelector displays grouped search results showing country and matc @@ -3908,12 +3916,12 @@ exports[`RegionSelector displays standalone countries in search results 1`] = ` style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc866", + "borderRadius": 24, "borderWidth": 1, "flexDirection": "row", - "height": 40, + "height": 48, "opacity": 1, "paddingHorizontal": 16, } @@ -3923,23 +3931,23 @@ exports[`RegionSelector displays standalone countries in search results 1`] = ` @@ -4575,12 +4585,12 @@ exports[`RegionSelector displays standalone states in search results 1`] = ` style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc866", + "borderRadius": 24, "borderWidth": 1, "flexDirection": "row", - "height": 40, + "height": 48, "opacity": 1, "paddingHorizontal": 16, } @@ -4590,23 +4600,23 @@ exports[`RegionSelector displays standalone states in search results 1`] = ` @@ -5385,12 +5397,12 @@ exports[`RegionSelector does not highlight country when regionCode does not matc style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc866", + "borderRadius": 24, "borderWidth": 1, "flexDirection": "row", - "height": 40, + "height": 48, "opacity": 1, "paddingHorizontal": 16, } @@ -5400,23 +5412,23 @@ exports[`RegionSelector does not highlight country when regionCode does not matc @@ -9435,12 +9457,12 @@ exports[`RegionSelector filters regions when search text is entered 1`] = ` style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc866", + "borderRadius": 24, "borderWidth": 1, "flexDirection": "row", - "height": 40, + "height": 48, "opacity": 1, "paddingHorizontal": 16, } @@ -9450,23 +9472,23 @@ exports[`RegionSelector filters regions when search text is entered 1`] = ` @@ -10156,12 +10180,12 @@ exports[`RegionSelector highlights country when regionCode exactly matches count style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc866", + "borderRadius": 24, "borderWidth": 1, "flexDirection": "row", - "height": 40, + "height": 48, "opacity": 1, "paddingHorizontal": 16, } @@ -10171,23 +10195,23 @@ exports[`RegionSelector highlights country when regionCode exactly matches count @@ -13258,12 +13288,12 @@ exports[`RegionSelector highlights state when selected in state view 1`] = ` style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc866", + "borderRadius": 24, "borderWidth": 1, "flexDirection": "row", - "height": 40, + "height": 48, "opacity": 1, "paddingHorizontal": 16, } @@ -13273,23 +13303,23 @@ exports[`RegionSelector highlights state when selected in state view 1`] = ` @@ -15889,12 +15923,12 @@ exports[`RegionSelector navigates back to countries view when back button is pre style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc866", + "borderRadius": 24, "borderWidth": 1, "flexDirection": "row", - "height": 40, + "height": 48, "opacity": 1, "paddingHorizontal": 16, } @@ -15904,23 +15938,23 @@ exports[`RegionSelector navigates back to countries view when back button is pre @@ -32477,12 +32555,12 @@ exports[`RegionSelector sets up back button in state view 1`] = ` style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc866", + "borderRadius": 24, "borderWidth": 1, "flexDirection": "row", - "height": 40, + "height": 48, "opacity": 1, "paddingHorizontal": 16, } @@ -32492,23 +32570,23 @@ exports[`RegionSelector sets up back button in state view 1`] = ` StyleSheet.create({ @@ -206,7 +206,7 @@ const Settings = () => { const oauthFlow = useSelector(selectSeedlessOnboardingLoginFlow); return ( - { return ( - diff --git a/app/components/Views/SimpleWebview/index.test.tsx b/app/components/Views/SimpleWebview/index.test.tsx index 4586ecfb3c6..abb6e9b5587 100644 --- a/app/components/Views/SimpleWebview/index.test.tsx +++ b/app/components/Views/SimpleWebview/index.test.tsx @@ -4,10 +4,10 @@ import SimpleWebview from './'; import { useNavigation } from '@react-navigation/native'; import Share from 'react-native-share'; import Logger from '../../../util/Logger'; -import getHeaderCenterNavbarOptions from '../../../component-library/components-temp/HeaderCenter/getHeaderCenterNavbarOptions'; +import getHeaderCompactStandardNavbarOptions from '../../../component-library/components-temp/HeaderCompactStandard/getHeaderCompactStandardNavbarOptions'; jest.mock( - '../../../component-library/components-temp/HeaderCenter/getHeaderCenterNavbarOptions', + '../../../component-library/components-temp/HeaderCompactStandard/getHeaderCompactStandardNavbarOptions', () => jest.fn(() => ({})), ); @@ -44,13 +44,14 @@ describe('SimpleWebview', () => { render(); expect(mockNavigation.setOptions).toHaveBeenCalled(); - expect(getHeaderCenterNavbarOptions).toHaveBeenCalled(); + expect(getHeaderCompactStandardNavbarOptions).toHaveBeenCalled(); }); it('calls Share.open when share button is pressed', () => { render(); - const call = (getHeaderCenterNavbarOptions as jest.Mock).mock.calls[0][0]; + const call = (getHeaderCompactStandardNavbarOptions as jest.Mock).mock + .calls[0][0]; const shareButton = call.endButtonIconProps[0]; shareButton.onPress(); @@ -63,7 +64,8 @@ describe('SimpleWebview', () => { render(); - const call = (getHeaderCenterNavbarOptions as jest.Mock).mock.calls[0][0]; + const call = (getHeaderCompactStandardNavbarOptions as jest.Mock).mock + .calls[0][0]; const shareButton = call.endButtonIconProps[0]; shareButton.onPress(); diff --git a/app/components/Views/SimpleWebview/index.tsx b/app/components/Views/SimpleWebview/index.tsx index 54f6f2b2a6b..54de8bf379e 100644 --- a/app/components/Views/SimpleWebview/index.tsx +++ b/app/components/Views/SimpleWebview/index.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect } from 'react'; import { SafeAreaView } from 'react-native-safe-area-context'; import { WebView } from '@metamask/react-native-webview'; -import getHeaderCenterNavbarOptions from '../../../component-library/components-temp/HeaderCenter/getHeaderCenterNavbarOptions'; +import getHeaderCompactStandardNavbarOptions from '../../../component-library/components-temp/HeaderCompactStandard/getHeaderCompactStandardNavbarOptions'; import { IconName } from '@metamask/design-system-react-native'; import Share from 'react-native-share'; // eslint-disable-line import/default import Logger from '../../../util/Logger'; @@ -34,7 +34,7 @@ const SimpleWebView = () => { useEffect(() => { const title = (route.params as { title?: string })?.title ?? ''; navigation.setOptions( - getHeaderCenterNavbarOptions({ + getHeaderCompactStandardNavbarOptions({ title, onBack: () => navigation.goBack(), includesTopInset: true, diff --git a/app/components/Views/SrpInput/__snapshots__/index.test.tsx.snap b/app/components/Views/SrpInput/__snapshots__/index.test.tsx.snap index 243ac7afd55..fe6219056f6 100644 --- a/app/components/Views/SrpInput/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/SrpInput/__snapshots__/index.test.tsx.snap @@ -23,12 +23,12 @@ exports[`SrpInput renders default settings correctly 1`] = ` style={ { "alignItems": "center", - "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", - "borderRadius": 8, + "backgroundColor": "#3c4d9d0f", + "borderColor": "#b7bbc866", + "borderRadius": 12, "borderWidth": 1, "flexDirection": "row", - "height": 40, + "height": 48, "opacity": 1, "paddingHorizontal": 16, } @@ -40,7 +40,7 @@ exports[`SrpInput renders default settings correctly 1`] = ` [ { "backgroundColor": "inherit", - "height": 38, + "height": 46, }, { "flex": 1, diff --git a/app/components/Views/SrpInput/index.test.tsx b/app/components/Views/SrpInput/index.test.tsx index 3db7a3f4474..d090211ad79 100644 --- a/app/components/Views/SrpInput/index.test.tsx +++ b/app/components/Views/SrpInput/index.test.tsx @@ -9,7 +9,6 @@ import { TEXTFIELD_STARTACCESSORY_TEST_ID, TEXTFIELD_ENDACCESSORY_TEST_ID, } from '../../../component-library/components/Form/TextField/TextField.constants'; -import { TextFieldSize } from '../../../component-library/components/Form/TextField/TextField.types'; import { act, fireEvent, render } from '@testing-library/react-native'; import Device from '../../../util/device'; @@ -34,14 +33,6 @@ describe('SrpInput', () => { expect(textFieldComponent).toBeOnTheScreen(); }); - it('renders the given size', () => { - const testSize = TextFieldSize.Lg; - - const wrapper = render(); - const textFieldComponent = wrapper.getByTestId(TEXTFIELD_TEST_ID); - expect(textFieldComponent.props.style.height).toBe(Number(testSize)); - }); - it('renders the startAccessory when provided', () => { const wrapper = render( } testID={INPUT_TEST_ID} />, diff --git a/app/components/Views/SrpInput/index.tsx b/app/components/Views/SrpInput/index.tsx index 0df32cc61c8..c1f1ffce378 100644 --- a/app/components/Views/SrpInput/index.tsx +++ b/app/components/Views/SrpInput/index.tsx @@ -21,12 +21,11 @@ import Input from './Input'; import styleSheet from '../../../component-library/components/Form/TextField/TextField.styles'; import { TextFieldProps } from '../../../component-library/components/Form/TextField/TextField.types'; import { - DEFAULT_TEXTFIELD_SIZE, - TOKEN_TEXTFIELD_INPUT_TEXT_VARIANT, TEXTFIELD_TEST_ID, TEXTFIELD_STARTACCESSORY_TEST_ID, TEXTFIELD_ENDACCESSORY_TEST_ID, } from '../../../component-library/components/Form/TextField/TextField.constants'; +import { TextVariant } from '../../../component-library/components/Texts/Text'; import Device from '../../../util/device'; const TextField = React.forwardRef< @@ -39,7 +38,6 @@ const TextField = React.forwardRef< ( { style, - size = DEFAULT_TEXTFIELD_SIZE, startAccessory, endAccessory, isError = false, @@ -63,7 +61,6 @@ const TextField = React.forwardRef< const { styles } = useStyles(styleSheet, { style, - size, isError, isDisabled, isFocused, @@ -122,7 +119,7 @@ const TextField = React.forwardRef< {inputElement ?? ( { }; }); -jest.mock('../../../component-library/components-temp/HeaderCenter', () => { - const ReactActual = jest.requireActual('react'); - const { - View: ReactNativeView, - Text: ReactNativeText, - Pressable: ReactNativePressable, - } = jest.requireActual('react-native'); - - return (props: { title: string; onClose: () => void }) => - ReactActual.createElement( - ReactNativeView, - { testID: 'tooltip-modal-header' }, - ReactActual.createElement(ReactNativeText, {}, props.title), +jest.mock( + '../../../component-library/components-temp/HeaderCompactStandard', + () => { + const ReactActual = jest.requireActual('react'); + const { + View: ReactNativeView, + Text: ReactNativeText, + Pressable: ReactNativePressable, + } = jest.requireActual('react-native'); + + return (props: { title: string; onClose: () => void }) => ReactActual.createElement( - ReactNativePressable, - { testID: 'tooltip-modal-close', onPress: props.onClose }, - ReactActual.createElement(ReactNativeText, {}, 'close'), - ), - ); -}); + ReactNativeView, + { testID: 'tooltip-modal-header' }, + ReactActual.createElement(ReactNativeText, {}, props.title), + ReactActual.createElement( + ReactNativePressable, + { testID: 'tooltip-modal-close', onPress: props.onClose }, + ReactActual.createElement(ReactNativeText, {}, 'close'), + ), + ); + }, +); jest.mock( '../../../component-library/components/BottomSheets/BottomSheet', diff --git a/app/components/Views/TooltipModal/index.tsx b/app/components/Views/TooltipModal/index.tsx index 4dfcba7b8c8..0c9a3a03c41 100644 --- a/app/components/Views/TooltipModal/index.tsx +++ b/app/components/Views/TooltipModal/index.tsx @@ -6,7 +6,7 @@ import BottomSheet, { import BottomSheetFooter, { ButtonsAlignment, } from '../../../component-library/components/BottomSheets/BottomSheetFooter'; -import HeaderCenter from '../../../component-library/components-temp/HeaderCenter'; +import HeaderCompactStandard from '../../../component-library/components-temp/HeaderCompactStandard'; import Text, { TextVariant, TextColor, @@ -47,7 +47,7 @@ const TooltipModal = () => { return ( - + {isValidElement(tooltip) ? ( tooltip diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx index b320627c20c..ee1cb60aab7 100644 --- a/app/components/Views/Wallet/index.tsx +++ b/app/components/Views/Wallet/index.tsx @@ -15,8 +15,10 @@ import { useBalanceRefresh } from './hooks'; import { ActivityIndicator, + DeviceEventEmitter, Linking, RefreshControl, + ScrollView, StyleSheet as RNStyleSheet, View, } from 'react-native'; @@ -569,6 +571,7 @@ const Wallet = ({ const route = useRoute>(); const walletRef = useRef(null); const walletTokensTabViewRef = useRef(null); + const scrollViewRef = useRef(null); const isMountedRef = useRef(true); const refreshInProgressRef = useRef(false); const [refreshing, setRefreshing] = useState(false); @@ -845,6 +848,27 @@ const Wallet = ({ }; }, []); + // Listen for scroll-to-token events (e.g., after claiming mUSD rewards) + // This handles scrolling in the homepage .map() mode where TokenList can't scroll directly + useEffect(() => { + const subscription = DeviceEventEmitter.addListener( + 'scrollToTokenIndex', + ({ offset }: { index: number; offset: number }) => { + // Add offset for content above tokens (balance, carousel, etc.) + // Approximate: AccountGroupBalance (~200px) + Carousel (~150px) + padding + const CONTENT_OFFSET_ABOVE_TOKENS = 400; + scrollViewRef.current?.scrollTo({ + y: CONTENT_OFFSET_ABOVE_TOKENS + offset, + animated: true, + }); + }, + ); + + return () => { + subscription.remove(); + }; + }, []); + useEffect(() => { // do not prompt for social login flow if ( @@ -1398,6 +1422,7 @@ const Wallet = ({ testID={WalletViewSelectorsIDs.WALLET_CONTAINER} > setOpen(false)} isTooltip> - setOpen(false)} closeButtonProps={{ diff --git a/app/components/Views/confirmations/components/UI/expandable/expandable.tsx b/app/components/Views/confirmations/components/UI/expandable/expandable.tsx index 624c8ee0171..db529266343 100644 --- a/app/components/Views/confirmations/components/UI/expandable/expandable.tsx +++ b/app/components/Views/confirmations/components/UI/expandable/expandable.tsx @@ -2,7 +2,7 @@ import React, { ReactNode, useState } from 'react'; import { TouchableOpacity, View } from 'react-native'; import { useStyles } from '../../../../../../component-library/hooks'; -import HeaderCenter from '../../../../../../component-library/components-temp/HeaderCenter'; +import HeaderCompactStandard from '../../../../../../component-library/components-temp/HeaderCompactStandard'; import BottomModal from '../bottom-modal'; import styleSheet from './expandable.styles'; @@ -45,7 +45,7 @@ const Expandable = ({ {expanded && ( setExpanded(false)}> - setExpanded(false)} closeButtonProps={{ diff --git a/app/components/Views/confirmations/components/UI/text-field-with-label/text-field-with-label.tsx b/app/components/Views/confirmations/components/UI/text-field-with-label/text-field-with-label.tsx index 10d2c0268db..0b465a0ac42 100644 --- a/app/components/Views/confirmations/components/UI/text-field-with-label/text-field-with-label.tsx +++ b/app/components/Views/confirmations/components/UI/text-field-with-label/text-field-with-label.tsx @@ -4,9 +4,7 @@ import { useStyles } from '../../../../../../component-library/hooks'; import Text, { TextVariant, } from '../../../../../../component-library/components/Texts/Text'; -import TextField, { - TextFieldSize, -} from '../../../../../../component-library/components/Form/TextField'; +import TextField from '../../../../../../component-library/components/Form/TextField'; import { TextFieldProps } from '../../../../../../component-library/components/Form/TextField/TextField.types'; import styleSheet from './text-field-with-label.styles'; @@ -27,12 +25,7 @@ export const TextFieldWithLabel = (props: TextFieldWithLabelProps) => { {label} )} - + {error && ( { ); }); - it('renders mUSD conversion send line title for child transaction', () => { - useTokenWithBalanceMock.mockReturnValue({ - symbol: SYMBOL_MOCK, - } as ReturnType); - - useTransactionDetailsMock.mockReturnValue({ - transactionMeta: { - id: transactionIdMock, - type: TransactionType.musdConversion, - metamaskPay: { - chainId: SOURCE_CHAIN_ID_MOCK, - tokenAddress: '0x123', - }, - } as unknown as TransactionMeta, - }); - - const { getByText } = render({ - transactions: [ - { ...TRANSACTION_META_MOCK, type: TransactionType.bridge }, - ], - }); - - expect( - getByText( - strings('transaction_details.summary_title.musd_convert_send', { - sourceSymbol: SYMBOL_MOCK, - sourceChain: SOURCE_NETWORK_NAME_MOCK, - }), - ), - ).toBeDefined(); - }); - - it('renders mUSD conversion receive line title', () => { - const { getByText } = render({ - transactions: [ - { ...TRANSACTION_META_MOCK, type: TransactionType.musdConversion }, - ], - }); - - expect( - getByText( - strings('transaction_details.summary_title.bridge_receive', { - targetSymbol: 'mUSD', - targetChain: SOURCE_NETWORK_NAME_MOCK, - }), - ), - ).toBeDefined(); - }); - it('renders perps deposit line title', () => { const { getByText } = render({ transactions: [ @@ -542,4 +493,272 @@ describe('TransactionDetailsSummary', () => { txHash: RECEIVE_HASH_MOCK, }); }); + + describe('mUSD Conversion', () => { + const MUSD_SEND_TX_ID = 'musd-send-tx-id'; + const MUSD_RECEIVE_TX_ID = 'musd-receive-tx-id'; + const MUSD_RECEIVE_HASH = '0x789abc' as Hex; + const SEND_HASH = '0x456def' as Hex; + + beforeEach(() => { + useTokenWithBalanceMock.mockReturnValue({ + symbol: SYMBOL_MOCK, + } as ReturnType); + + useTransactionDetailsMock.mockReturnValue({ + transactionMeta: { + id: transactionIdMock, + chainId: SOURCE_CHAIN_ID_MOCK, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + type: TransactionType.musdConversion, + requiredTransactionIds: [MUSD_SEND_TX_ID, MUSD_RECEIVE_TX_ID], + metamaskPay: { + chainId: SOURCE_CHAIN_ID_MOCK, + tokenAddress: '0x123', + }, + } as unknown as TransactionMeta, + }); + }); + + it('renders exactly 2 lines for mUSD conversion', () => { + const { getByText } = render({ + transactions: [ + { + id: MUSD_SEND_TX_ID, + chainId: SOURCE_CHAIN_ID_MOCK, + hash: SEND_HASH, + type: TransactionType.relayDeposit, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + }, + { + id: MUSD_RECEIVE_TX_ID, + chainId: SOURCE_CHAIN_ID_MOCK, + hash: MUSD_RECEIVE_HASH, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + transferInformation: { + contractAddress: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + decimals: 6, + symbol: 'MUSD', + }, + }, + { + id: transactionIdMock, + chainId: SOURCE_CHAIN_ID_MOCK, + type: TransactionType.musdConversion, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + }, + ], + }); + + // Sent line + expect( + getByText( + strings('transaction_details.summary_title.musd_convert_send', { + sourceSymbol: SYMBOL_MOCK, + sourceChain: SOURCE_NETWORK_NAME_MOCK, + }), + ), + ).toBeDefined(); + + // Receive line + expect( + getByText( + strings('transaction_details.summary_title.bridge_receive', { + targetSymbol: 'mUSD', + targetChain: SOURCE_NETWORK_NAME_MOCK, + }), + ), + ).toBeDefined(); + }); + + it('renders sent line with loading state when source token is not available', () => { + useTokenWithBalanceMock.mockReturnValue( + {} as ReturnType, + ); + + const { getByText } = render({ + transactions: [ + { + id: MUSD_SEND_TX_ID, + chainId: SOURCE_CHAIN_ID_MOCK, + hash: SEND_HASH, + type: TransactionType.relayDeposit, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + }, + { + id: transactionIdMock, + chainId: SOURCE_CHAIN_ID_MOCK, + type: TransactionType.musdConversion, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + }, + ], + }); + + expect( + getByText( + strings('transaction_details.summary_title.bridge_send_loading'), + ), + ).toBeDefined(); + }); + + it('skips mUSD receive transactions and uses musdConversion hash for receive line', () => { + render({ + transactions: [ + { + id: MUSD_SEND_TX_ID, + chainId: SOURCE_CHAIN_ID_MOCK, + hash: SEND_HASH, + type: TransactionType.relayDeposit, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + }, + { + id: MUSD_RECEIVE_TX_ID, + chainId: SOURCE_CHAIN_ID_MOCK, + hash: MUSD_RECEIVE_HASH, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + transferInformation: { + contractAddress: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + decimals: 6, + symbol: 'MUSD', + }, + }, + { + id: transactionIdMock, + chainId: SOURCE_CHAIN_ID_MOCK, + type: TransactionType.musdConversion, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + }, + ], + }); + + // mUSD receive transaction is skipped, receive line uses musdConversion tx hash (undefined here) + expect(useMultichainBlockExplorerTxUrlMock).not.toHaveBeenCalledWith({ + chainId: Number(SOURCE_CHAIN_ID_MOCK), + txHash: MUSD_RECEIVE_HASH, + }); + }); + + it('falls back to transactionMeta.hash when no separate receive transaction exists', () => { + const PARENT_TX_HASH = '0xparenthash' as Hex; + + useTransactionDetailsMock.mockReturnValue({ + transactionMeta: { + id: transactionIdMock, + chainId: SOURCE_CHAIN_ID_MOCK, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + type: TransactionType.musdConversion, + requiredTransactionIds: [MUSD_SEND_TX_ID], + hash: PARENT_TX_HASH, + metamaskPay: { + chainId: SOURCE_CHAIN_ID_MOCK, + tokenAddress: '0x123', + }, + } as unknown as TransactionMeta, + }); + + render({ + transactions: [ + { + id: MUSD_SEND_TX_ID, + chainId: SOURCE_CHAIN_ID_MOCK, + hash: SEND_HASH, + type: TransactionType.relayDeposit, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + }, + { + id: transactionIdMock, + chainId: SOURCE_CHAIN_ID_MOCK, + hash: PARENT_TX_HASH, + type: TransactionType.musdConversion, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + }, + ], + }); + + // Falls back to transactionMeta.hash when no mUSD receive transaction exists + expect(useMultichainBlockExplorerTxUrlMock).toHaveBeenCalledWith({ + chainId: Number(SOURCE_CHAIN_ID_MOCK), + txHash: PARENT_TX_HASH, + }); + }); + + it('renders receive line without block explorer link when hash is 0x0', () => { + useTransactionDetailsMock.mockReturnValue({ + transactionMeta: { + id: transactionIdMock, + chainId: SOURCE_CHAIN_ID_MOCK, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + type: TransactionType.musdConversion, + requiredTransactionIds: [MUSD_SEND_TX_ID], + hash: '0x0', + metamaskPay: { + chainId: SOURCE_CHAIN_ID_MOCK, + tokenAddress: '0x123', + }, + } as unknown as TransactionMeta, + }); + + render({ + transactions: [ + { + id: MUSD_SEND_TX_ID, + chainId: SOURCE_CHAIN_ID_MOCK, + hash: SEND_HASH, + type: TransactionType.relayDeposit, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + }, + { + id: transactionIdMock, + chainId: SOURCE_CHAIN_ID_MOCK, + hash: '0x0', + type: TransactionType.musdConversion, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + }, + ], + }); + + // No fallback when hash is 0x0 + expect(useMultichainBlockExplorerTxUrlMock).toHaveBeenCalledWith({ + chainId: Number(SOURCE_CHAIN_ID_MOCK), + txHash: undefined, + }); + }); + + it('uses send transaction hash for sent line block explorer link', () => { + render({ + transactions: [ + { + id: MUSD_SEND_TX_ID, + chainId: SOURCE_CHAIN_ID_MOCK, + hash: SEND_HASH, + type: TransactionType.relayDeposit, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + }, + { + id: MUSD_RECEIVE_TX_ID, + chainId: SOURCE_CHAIN_ID_MOCK, + hash: MUSD_RECEIVE_HASH, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + transferInformation: { + contractAddress: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + decimals: 6, + symbol: 'MUSD', + }, + }, + { + id: transactionIdMock, + chainId: SOURCE_CHAIN_ID_MOCK, + type: TransactionType.musdConversion, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + }, + ], + }); + + // Check block explorer URL is called with send hash + expect(useMultichainBlockExplorerTxUrlMock).toHaveBeenCalledWith({ + chainId: Number(SOURCE_CHAIN_ID_MOCK), + txHash: SEND_HASH, + }); + }); + }); }); diff --git a/app/components/Views/confirmations/components/activity/transaction-details-summary/transaction-details-summary.tsx b/app/components/Views/confirmations/components/activity/transaction-details-summary/transaction-details-summary.tsx index 4f6e5673375..314f713d0d3 100644 --- a/app/components/Views/confirmations/components/activity/transaction-details-summary/transaction-details-summary.tsx +++ b/app/components/Views/confirmations/components/activity/transaction-details-summary/transaction-details-summary.tsx @@ -98,6 +98,7 @@ function TransactionSummary({ chainId: receiveChainId, hash: receiveHash, isReceiveOnly, + skip, sourceNetworkName, sourceSymbol, targetNetworkName, @@ -107,6 +108,11 @@ function TransactionSummary({ const allBridgeHistory = useSelector(selectBridgeHistoryForAccount); + // Skip rendering for transactions handled elsewhere (e.g., mUSD receive) + if (skip) { + return null; + } + const approvalBridgeHistory = Object.values(allBridgeHistory).find( (h) => h.approvalTxId === transaction.id, ); @@ -274,7 +280,6 @@ function getLineTitle({ transactionMeta: TransactionMeta; }): string | undefined { const { type } = transactionMeta; - const { type: parentType } = parentTransaction ?? {}; const approveSymbol = approvalBridgeHistory?.quote?.srcAsset?.symbol; if (isReceive) { @@ -286,13 +291,21 @@ function getLineTitle({ : strings('transaction_details.summary_title.bridge_receive_loading'); } + // mUSD conversion: use specific string for send line + if ( + parentTransaction && + hasTransactionType(parentTransaction, [TransactionType.musdConversion]) && + hasTransactionType(transactionMeta, [TransactionType.relayDeposit]) + ) { + return symbol && networkName + ? strings('transaction_details.summary_title.musd_convert_send', { + sourceSymbol: symbol, + sourceChain: networkName, + }) + : strings('transaction_details.summary_title.bridge_send_loading'); + } + if (symbol && networkName) { - if (parentType === TransactionType.musdConversion) { - return strings('transaction_details.summary_title.musd_convert_send', { - sourceSymbol: symbol, - sourceChain: networkName, - }); - } return strings('transaction_details.summary_title.bridge_send', { sourceSymbol: symbol, sourceChain: networkName, @@ -329,6 +342,7 @@ function useBridgeReceiveData( chainId?: Hex; hash?: Hex; isReceiveOnly?: boolean; + skip?: boolean; sourceNetworkName?: string; sourceSymbol?: string; targetNetworkName?: string; @@ -356,15 +370,6 @@ function useBridgeReceiveData( const sourceNetworkName = useNetworkName(transaction.chainId); const targetNetworkName = useNetworkName(chainId); - if (hasTransactionType(transaction, [TransactionType.musdConversion])) { - return { - chainId: transaction.chainId, - isReceiveOnly: true, - targetNetworkName: sourceNetworkName, - targetSymbol: 'mUSD', - }; - } - if (hasTransactionType(transaction, [TransactionType.perpsDeposit])) { return { chainId: CHAIN_IDS.ARBITRUM, @@ -383,9 +388,27 @@ function useBridgeReceiveData( }; } + // mUSD conversion: main transaction renders receive line only + if (hasTransactionType(transaction, [TransactionType.musdConversion])) { + const receiveData = { + chainId: transaction.chainId, + hash: undefined as Hex | undefined, + isReceiveOnly: true, + targetNetworkName: sourceNetworkName, + targetSymbol: 'mUSD', + }; + + if (!transaction.hash || transaction.hash === '0x0') { + return receiveData; + } + + receiveData.hash = transaction.hash as Hex; + + return receiveData; + } + if ( hasTransactionType(parentTransaction, [ - TransactionType.musdConversion, TransactionType.perpsDeposit, TransactionType.predictDeposit, ]) @@ -396,6 +419,18 @@ function useBridgeReceiveData( }; } + // mUSD conversion: relayDeposit renders send line only + if (hasTransactionType(parentTransaction, [TransactionType.musdConversion])) { + if (hasTransactionType(transaction, [TransactionType.relayDeposit])) { + return { + sourceNetworkName, + sourceSymbol: sourceToken?.symbol, + }; + } + // Skip other mUSD transactions for send line show relay deposit only + return { skip: true }; + } + return { chainId, hash, diff --git a/app/components/Views/confirmations/components/activity/transaction-details/transaction-details.test.tsx b/app/components/Views/confirmations/components/activity/transaction-details/transaction-details.test.tsx index 3a604cb2778..d2aa5e45848 100644 --- a/app/components/Views/confirmations/components/activity/transaction-details/transaction-details.test.tsx +++ b/app/components/Views/confirmations/components/activity/transaction-details/transaction-details.test.tsx @@ -234,6 +234,16 @@ describe('TransactionDetails', () => { }); }); + describe('navigation options', () => { + it('configures navigation with back arrow on left instead of close button on right', () => { + render(); + + const navOptions = mockSetOptions.mock.calls[0][0]; + expect(navOptions.headerLeft()).not.toBeNull(); + expect(navOptions.headerRight()).toBeNull(); + }); + }); + describe('SUMMARY_SECTION_TYPES', () => { it.each([ TransactionType.musdConversion, diff --git a/app/components/Views/confirmations/components/activity/transaction-details/transaction-details.tsx b/app/components/Views/confirmations/components/activity/transaction-details/transaction-details.tsx index 8021c078de6..336305dd319 100644 --- a/app/components/Views/confirmations/components/activity/transaction-details/transaction-details.tsx +++ b/app/components/Views/confirmations/components/activity/transaction-details/transaction-details.tsx @@ -42,7 +42,7 @@ export function TransactionDetails() { useEffect(() => { navigation.setOptions( - getNavigationOptionsTitle(title, navigation, true, colors), + getNavigationOptionsTitle(title, navigation, false, colors), ); }, [colors, navigation, theme, title]); diff --git a/app/components/Views/confirmations/components/gas/gas-fee-token-modal/gas-fee-token-modal.tsx b/app/components/Views/confirmations/components/gas/gas-fee-token-modal/gas-fee-token-modal.tsx index b98598b01e5..c1fe29ab469 100644 --- a/app/components/Views/confirmations/components/gas/gas-fee-token-modal/gas-fee-token-modal.tsx +++ b/app/components/Views/confirmations/components/gas/gas-fee-token-modal/gas-fee-token-modal.tsx @@ -7,7 +7,7 @@ import { updateSelectedGasFeeToken } from '../../../../../../util/transaction-co import BottomModal from '../../UI/bottom-modal'; import { View } from 'react-native'; import { useStyles } from '../../../../../../component-library/hooks'; -import HeaderCenter from '../../../../../../component-library/components-temp/HeaderCenter'; +import HeaderCompactStandard from '../../../../../../component-library/components-temp/HeaderCompactStandard'; import styleSheet from './gas-fee-token-modal.styles'; import { GasFeeTokenListItem } from '../gas-fee-token-list-item'; import { Hex } from '@metamask/utils'; @@ -56,7 +56,7 @@ export function GasFeeTokenModal({ onClose }: { onClose?: () => void }) { } > - ({ - useConfirmationAlertMetrics: () => ({ - trackInlineAlertClicked: jest.fn(), - trackAlertActionClicked: jest.fn(), - trackAlertRendered: jest.fn(), - }), -})); - -function render() { - const state = merge( - {}, - simpleSendTransactionControllerMock, - transactionApprovalControllerMock, - otherControllersMock, - ); - - return renderWithProvider(, { state }); -} - -describe('PerpsDepositFees', () => { - const useTransactionPayQuotesMock = jest.mocked(useTransactionPayQuotes); - const useIsTransactionPayLoadingMock = jest.mocked( - useIsTransactionPayLoading, - ); - const useTransactionPayRequiredTokensMock = jest.mocked( - useTransactionPayRequiredTokens, - ); - const useTransactionPaySourceAmountsMock = jest.mocked( - useTransactionPaySourceAmounts, - ); - const useTransactionPayTotalsMock = jest.mocked(useTransactionPayTotals); - - beforeEach(() => { - jest.resetAllMocks(); - - useTransactionPayQuotesMock.mockReturnValue([ - {} as TransactionPayQuote, - ]); - useIsTransactionPayLoadingMock.mockReturnValue(false); - useTransactionPayRequiredTokensMock.mockReturnValue([]); - useTransactionPaySourceAmountsMock.mockReturnValue([]); - useTransactionPayTotalsMock.mockReturnValue({ - fees: { - provider: { usd: '1.00' }, - sourceNetwork: { estimate: { usd: '0.20' } }, - targetNetwork: { usd: '0.03' }, - }, - total: { usd: '123.456' }, - } as TransactionPayTotals); - }); - - it('renders fee rows when result is ready', () => { - const { getByTestId } = render(); - - expect(getByTestId('bridge-fee-row')).toBeOnTheScreen(); - expect(getByTestId('total-row')).toBeOnTheScreen(); - }); - - it('renders fee rows when quotes are loading', () => { - useIsTransactionPayLoadingMock.mockReturnValue(true); - - const { getByTestId } = render(); - - // When loading, isResultReady is true, so fee rows container should be shown - // But BridgeFeeRow itself shows skeleton when loading, so we check for skeleton - expect(getByTestId('bridge-fee-row-skeleton')).toBeOnTheScreen(); - }); - - it('renders fee rows when no quotes and no source amounts', () => { - useTransactionPayQuotesMock.mockReturnValue([]); - useIsTransactionPayLoadingMock.mockReturnValue(false); - useTransactionPaySourceAmountsMock.mockReturnValue([]); - - const { getByTestId } = render(); - - // When no source amounts, isResultReady is true (!hasSourceAmount), so fee rows should be shown - expect(getByTestId('bridge-fee-row')).toBeOnTheScreen(); - }); - - it('renders fee rows when quotes exist', () => { - useTransactionPayQuotesMock.mockReturnValue([ - {} as TransactionPayQuote, - ]); - useIsTransactionPayLoadingMock.mockReturnValue(false); - useTransactionPaySourceAmountsMock.mockReturnValue([ - { - targetTokenAddress: '0x123', - amount: '100', - } as unknown as TransactionPaySourceAmount, - ]); - - const { getByTestId } = render(); - - expect(getByTestId('bridge-fee-row')).toBeOnTheScreen(); - }); - - it('renders fee rows when required tokens exist but no matching source amounts', () => { - useTransactionPayQuotesMock.mockReturnValue([]); - useIsTransactionPayLoadingMock.mockReturnValue(false); - useTransactionPayRequiredTokensMock.mockReturnValue([ - { - address: '0x123', - skipIfBalance: false, - } as unknown as TransactionPayRequiredToken, - ]); - useTransactionPaySourceAmountsMock.mockReturnValue([ - { - targetTokenAddress: '0x456', // Different address - no match - amount: '100', - } as unknown as TransactionPaySourceAmount, - ]); - - const { getByTestId } = render(); - - // When hasSourceAmount is false (no match), isResultReady is true, so fee rows should be shown - // But actually, hasSourceAmount checks if sourceAmounts match requiredTokens - // Since addresses don't match, hasSourceAmount is false, so !hasSourceAmount is true - // So isResultReady is true, and fee rows should be shown - expect(getByTestId('bridge-fee-row')).toBeOnTheScreen(); - }); - - it('renders skeletons when required tokens match source amounts', () => { - useTransactionPayQuotesMock.mockReturnValue([]); - useIsTransactionPayLoadingMock.mockReturnValue(false); - useTransactionPayRequiredTokensMock.mockReturnValue([ - { - address: '0x123', - skipIfBalance: false, - } as unknown as TransactionPayRequiredToken, - ]); - useTransactionPaySourceAmountsMock.mockReturnValue([ - { - targetTokenAddress: '0x123', // Matching address - amount: '100', - } as unknown as TransactionPaySourceAmount, - ]); - - const { queryByTestId } = render(); - - // When hasSourceAmount is true (match found), isResultReady is false - // (because !hasSourceAmount is false), so skeletons should be shown - expect(queryByTestId('bridge-fee-row')).toBeNull(); - }); - - it('renders fee rows when required token has skipIfBalance true', () => { - useTransactionPayQuotesMock.mockReturnValue([]); - useIsTransactionPayLoadingMock.mockReturnValue(false); - useTransactionPayRequiredTokensMock.mockReturnValue([ - { - address: '0x123', - skipIfBalance: true, // Should be skipped in hasSourceAmount check - } as unknown as TransactionPayRequiredToken, - ]); - useTransactionPaySourceAmountsMock.mockReturnValue([ - { - targetTokenAddress: '0x123', - amount: '100', - } as unknown as TransactionPaySourceAmount, - ]); - - const { getByTestId } = render(); - - // When skipIfBalance is true, that token is not considered in hasSourceAmount - // So hasSourceAmount is false, !hasSourceAmount is true, isResultReady is true - // So fee rows should be shown - expect(getByTestId('bridge-fee-row')).toBeOnTheScreen(); - }); -}); diff --git a/app/components/Views/confirmations/components/info/external/perps/perps-deposit-fees.tsx b/app/components/Views/confirmations/components/info/external/perps/perps-deposit-fees.tsx deleted file mode 100644 index a858cbe390d..00000000000 --- a/app/components/Views/confirmations/components/info/external/perps/perps-deposit-fees.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import React from 'react'; -import { Box } from '../../../../../../UI/Box/Box'; -import { BridgeFeeRow } from '../../../rows/bridge-fee-row'; -import { BridgeTimeRow } from '../../../rows/bridge-time-row'; -import { PercentageRow } from '../../../rows/percentage-row'; -import { TotalRow } from '../../../rows/total-row'; -import { InfoRowSkeleton } from '../../../UI/info-row/info-row'; -import { - useIsTransactionPayLoading, - useTransactionPayQuotes, - useTransactionPayRequiredTokens, - useTransactionPaySourceAmounts, -} from '../../../../hooks/pay/useTransactionPayData'; - -export const PerpsDepositFees = () => { - const isResultReady = useIsResultReady(); - - if (!isResultReady) { - return ( - - - - - - - ); - } - - return ( - - - - - - - ); -}; - -function useIsResultReady() { - const quotes = useTransactionPayQuotes(); - const isQuotesLoading = useIsTransactionPayLoading(); - const requiredTokens = useTransactionPayRequiredTokens(); - const sourceAmounts = useTransactionPaySourceAmounts(); - - const hasSourceAmount = sourceAmounts?.some((a) => - requiredTokens.some( - (rt) => - rt.address.toLowerCase() === a.targetTokenAddress.toLowerCase() && - !rt.skipIfBalance, - ), - ); - - return isQuotesLoading || Boolean(quotes?.length) || !hasSourceAmount; -} diff --git a/app/components/Views/confirmations/components/modals/estimates-modal/estimates-modal.tsx b/app/components/Views/confirmations/components/modals/estimates-modal/estimates-modal.tsx index 0833519c23c..42cd40a8b2d 100644 --- a/app/components/Views/confirmations/components/modals/estimates-modal/estimates-modal.tsx +++ b/app/components/Views/confirmations/components/modals/estimates-modal/estimates-modal.tsx @@ -6,7 +6,7 @@ import { strings } from '../../../../../../../locales/i18n'; import BottomModal from '../../UI/bottom-modal'; import { GasOption } from '../../../components/gas/gas-option'; import { useGasOptions } from '../../../hooks/gas/useGasOptions'; -import HeaderCenter from '../../../../../../component-library/components-temp/HeaderCenter'; +import HeaderCompactStandard from '../../../../../../component-library/components-temp/HeaderCompactStandard'; import { GasModalType } from '../../../constants/gas'; import styleSheet from './estimates-modal.styles'; @@ -27,7 +27,7 @@ export const EstimatesModal = ({ onSwipeComplete={handleCloseModals} > - diff --git a/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.test.tsx b/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.test.tsx index 4b53e5d3c1b..7c6dcb595db 100644 --- a/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.test.tsx +++ b/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.test.tsx @@ -25,11 +25,15 @@ import { Hex } from '@metamask/utils'; import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest'; import { EMPTY_ADDRESS } from '../../../../../../constants/transaction'; import { getAvailableTokens } from '../../../utils/transaction-pay'; +import { usePerpsPaymentToken } from '../../../../../UI/Perps/hooks/usePerpsPaymentToken'; +import { usePerpsBalanceTokenFilter } from '../../../../../UI/Perps/hooks/usePerpsBalanceTokenFilter'; jest.mock('../../../hooks/pay/useTransactionPayToken'); jest.mock('../../../hooks/pay/useTransactionPayData'); jest.mock('../../../hooks/transactions/useTransactionMetadataRequest'); jest.mock('../../../utils/transaction-pay'); +jest.mock('../../../../../UI/Perps/hooks/usePerpsPaymentToken'); +jest.mock('../../../../../UI/Perps/hooks/usePerpsBalanceTokenFilter'); jest.mock('../../../hooks/send/useAccountTokens', () => ({ useAccountTokens: () => [], @@ -158,6 +162,7 @@ function render({ minimumFiatBalance }: { minimumFiatBalance?: number } = {}) { describe('PayWithModal', () => { const setPayTokenMock = jest.fn(); + const onPerpsPaymentTokenChangeMock = jest.fn(); const useTransactionPayTokenMock = jest.mocked(useTransactionPayToken); const getAvailableTokensMock = jest.mocked(getAvailableTokens); const useTransactionPayRequiredTokensMock = jest.mocked( @@ -166,6 +171,10 @@ describe('PayWithModal', () => { const useTransactionMetadataRequestMock = jest.mocked( useTransactionMetadataRequest, ); + const usePerpsPaymentTokenMock = jest.mocked(usePerpsPaymentToken); + const usePerpsBalanceTokenFilterMock = jest.mocked( + usePerpsBalanceTokenFilter, + ); beforeEach(() => { jest.resetAllMocks(); @@ -189,6 +198,14 @@ describe('PayWithModal', () => { }, type: TransactionType.simpleSend, } as unknown as ReturnType); + + usePerpsPaymentTokenMock.mockReturnValue({ + onPaymentTokenChange: onPerpsPaymentTokenChangeMock, + } as unknown as ReturnType); + + usePerpsBalanceTokenFilterMock.mockReturnValue( + jest.fn((tokens: AssetType[]) => tokens), + ); }); it('renders tokens', async () => { @@ -216,5 +233,49 @@ describe('PayWithModal', () => { chainId: TOKENS_MOCK[1].chainId, }); }); + + it('calls onPerpsPaymentTokenChange via close callback when type is perpsDepositAndOrder', async () => { + useTransactionMetadataRequestMock.mockReturnValue({ + id: transactionIdMock, + chainId: CHAIN_ID_1_MOCK, + networkClientId: '', + status: TransactionStatus.unapproved, + time: 0, + txParams: { from: EMPTY_ADDRESS }, + type: TransactionType.perpsDepositAndOrder, + } as unknown as ReturnType); + + const { getByText } = render(); + + await waitFor(() => { + fireEvent.press(getByText('Test Token 1')); + }); + + expect(onPerpsPaymentTokenChangeMock).toHaveBeenCalledWith( + expect.objectContaining({ + address: TOKENS_MOCK[1].address, + chainId: TOKENS_MOCK[1].chainId, + }), + ); + }); + }); + + it('uses perpsBalanceTokenFilter when transaction type is perpsDepositAndOrder', () => { + const perpsFilterFn = jest.fn((tokens: AssetType[]) => tokens); + usePerpsBalanceTokenFilterMock.mockReturnValue(perpsFilterFn); + + useTransactionMetadataRequestMock.mockReturnValue({ + id: transactionIdMock, + chainId: CHAIN_ID_1_MOCK, + networkClientId: '', + status: TransactionStatus.unapproved, + time: 0, + txParams: { from: EMPTY_ADDRESS }, + type: TransactionType.perpsDepositAndOrder, + } as unknown as ReturnType); + + render(); + + expect(perpsFilterFn).toHaveBeenCalledWith(TOKENS_MOCK); }); }); diff --git a/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx b/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx index 5e74760c0d3..62a8e72b28d 100644 --- a/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx +++ b/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx @@ -6,7 +6,7 @@ import { Asset } from '../../send/asset'; import BottomSheet, { BottomSheetRef, } from '../../../../../../component-library/components/BottomSheets/BottomSheet'; -import HeaderCenter from '../../../../../../component-library/components-temp/HeaderCenter'; +import HeaderCompactStandard from '../../../../../../component-library/components-temp/HeaderCompactStandard'; import { AssetType } from '../../../types/token'; import { useTransactionPayRequiredTokens } from '../../../hooks/pay/useTransactionPayData'; import { getAvailableTokens } from '../../../utils/transaction-pay'; @@ -16,6 +16,8 @@ import { hasTransactionType } from '../../../utils/transaction'; import { useMusdConversionTokens } from '../../../../../UI/Earn/hooks/useMusdConversionTokens'; import { HIDE_NETWORK_FILTER_TYPES } from '../../../constants/confirmations'; import { useMusdPaymentToken } from '../../../../../UI/Earn/hooks/useMusdPaymentToken'; +import { usePerpsBalanceTokenFilter } from '../../../../../UI/Perps/hooks/usePerpsBalanceTokenFilter'; +import { usePerpsPaymentToken } from '../../../../../UI/Perps/hooks/usePerpsPaymentToken'; export function PayWithModal() { const transactionMeta = useTransactionMetadataRequest(); @@ -29,6 +31,9 @@ export function PayWithModal() { const { filterAllowedTokens: musdTokenFilter } = useMusdConversionTokens(); const { onPaymentTokenChange: onMusdPaymentTokenChange } = useMusdPaymentToken(); + const { onPaymentTokenChange: onPerpsPaymentTokenChange } = + usePerpsPaymentToken(); + const perpsBalanceTokenFilter = usePerpsBalanceTokenFilter(); const close = useCallback((onClosed?: () => void) => { // Called after the bottom sheet's closing animation completes. @@ -44,6 +49,15 @@ export function PayWithModal() { return; } + if ( + hasTransactionType(transactionMeta, [ + TransactionType.perpsDepositAndOrder, + ]) + ) { + close(() => onPerpsPaymentTokenChange(token)); + return; + } + close(() => { setPayToken({ address: token.address as Hex, @@ -51,7 +65,13 @@ export function PayWithModal() { }); }); }, - [close, onMusdPaymentTokenChange, setPayToken, transactionMeta], + [ + close, + onMusdPaymentTokenChange, + onPerpsPaymentTokenChange, + setPayToken, + transactionMeta, + ], ); const tokenFilter = useCallback( @@ -68,9 +88,23 @@ export function PayWithModal() { return musdTokenFilter(availableTokens); } + if ( + hasTransactionType(transactionMeta, [ + TransactionType.perpsDepositAndOrder, + ]) + ) { + return perpsBalanceTokenFilter(availableTokens); + } + return availableTokens; }, - [musdTokenFilter, payToken, requiredTokens, transactionMeta], + [ + musdTokenFilter, + payToken, + requiredTokens, + transactionMeta, + perpsBalanceTokenFilter, + ], ); return ( @@ -79,9 +113,9 @@ export function PayWithModal() { ref={bottomSheetRef} keyboardAvoidingViewEnabled={false} > - close()} /> diff --git a/app/components/Views/confirmations/components/recipient-input/recipient-input.tsx b/app/components/Views/confirmations/components/recipient-input/recipient-input.tsx index 497e170adcc..09bcacdf746 100644 --- a/app/components/Views/confirmations/components/recipient-input/recipient-input.tsx +++ b/app/components/Views/confirmations/components/recipient-input/recipient-input.tsx @@ -12,8 +12,7 @@ import { import { strings } from '../../../../../../locales/i18n'; import TextField from '../../../../../component-library/components/Form/TextField'; import Input from '../../../../../component-library/components/Form/TextField/foundation/Input'; -import { TextFieldSize } from '../../../../../component-library/components/Form/TextField/TextField.types'; -import { TOKEN_TEXTFIELD_INPUT_TEXT_VARIANT } from '../../../../../component-library/components/Form/TextField/TextField.constants'; +import { TextVariant } from '../../../../../component-library/components/Texts/Text'; import ClipboardManager from '../../../../../core/ClipboardManager'; import { useSendContext } from '../../context/send-context/send-context'; @@ -105,12 +104,11 @@ export const RecipientInput = ({ return ( = (props = {}) => { ? strings('send.search_tokens') : strings('send.search_tokens_and_nfts') } - size={TextFieldSize.Lg} showClearButton={searchQuery.length > 0} onPressClearButton={clearSearch} style={{ diff --git a/app/components/Views/confirmations/hooks/send/useSendNavbar.tsx b/app/components/Views/confirmations/hooks/send/useSendNavbar.tsx index 6f59bca03d4..61b2555887f 100644 --- a/app/components/Views/confirmations/hooks/send/useSendNavbar.tsx +++ b/app/components/Views/confirmations/hooks/send/useSendNavbar.tsx @@ -1,7 +1,7 @@ import { useCallback } from 'react'; import { useNavigation, useNavigationState } from '@react-navigation/native'; -import getHeaderCenterNavbarOptions from '../../../../../component-library/components-temp/HeaderCenter/getHeaderCenterNavbarOptions'; +import getHeaderCompactStandardNavbarOptions from '../../../../../component-library/components-temp/HeaderCompactStandard/getHeaderCompactStandardNavbarOptions'; import { strings } from '../../../../../../locales/i18n'; import Routes from '../../../../../constants/navigation/Routes'; import { useSendActions } from './useSendActions'; @@ -49,7 +49,7 @@ export function useSendNavbar() { }, [navigation, sendStackState]); return { - Amount: getHeaderCenterNavbarOptions({ + Amount: getHeaderCompactStandardNavbarOptions({ title: strings('send.title'), onBack: handleBackPress, onClose: handleCancelPress, @@ -57,13 +57,13 @@ export function useSendNavbar() { closeButtonProps: { testID: 'send-navbar-close-button' }, includesTopInset: true, }), - Asset: getHeaderCenterNavbarOptions({ + Asset: getHeaderCompactStandardNavbarOptions({ onBack: handleCancelPress, backButtonProps: { testID: 'send-navbar-back-button' }, title: strings('send.title'), includesTopInset: true, }), - Recipient: getHeaderCenterNavbarOptions({ + Recipient: getHeaderCompactStandardNavbarOptions({ title: strings('send.title'), onBack: handleBackPress, onClose: handleCancelPress, diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index 16a619cb326..effe89bfb4d 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -28,6 +28,7 @@ const Routes = { UNSUPPORTED_REGION: 'RampUnsupportedRegionModal', UNSUPPORTED_TOKEN: 'RampUnsupportedTokenModal', PAYMENT_METHOD_SELECTOR: 'RampPaymentMethodSelectorModal', + PAYMENT_SELECTION: 'RampPaymentSelectionModal', SETTINGS: 'RampSettingsModal', BUILD_QUOTE_SETTINGS: 'RampBuildQuoteSettingsModal', }, @@ -119,6 +120,7 @@ const Routes = { REWARDS_REFERRAL_BOTTOM_SHEET_MODAL: 'RewardsReferralBottomSheetModal', OTA_UPDATES_MODAL: 'OTAUpdatesModal', REWARDS_END_OF_SEASON_CLAIM_BOTTOM_SHEET: 'EndOfSeasonClaimBottomSheet', + CLAIM_ON_LINEA: 'ClaimOnLineaModal', }, ONBOARDING: { ROOT_NAV: 'OnboardingRootNav', diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index de6e838faaa..e0c44dae306 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -546,6 +546,11 @@ 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_PUSH_PROVISIONING_STARTED = 'Card Push Provisioning Started', + CARD_PUSH_PROVISIONING_COMPLETED = 'Card Push Provisioning Completed', + CARD_PUSH_PROVISIONING_FAILED = 'Card Push Provisioning Failed', + CARD_PUSH_PROVISIONING_CANCELED = 'Card Push Provisioning Canceled', + CARD_ADD_TO_WALLET_CLICKED = 'Card Add To Wallet Clicked', CARD_METAL_CHECKOUT_VIEWED = 'Card Metal Checkout Viewed', CARD_METAL_CHECKOUT_STARTED = 'Card Metal Checkout Started', CARD_METAL_CHECKOUT_COMPLETED = 'Card Metal Checkout Completed', @@ -1443,6 +1448,21 @@ const events = { CARD_DELEGATION_PROCESS_USER_CANCELED: generateOpt( EVENT_NAME.CARD_DELEGATION_PROCESS_USER_CANCELED, ), + CARD_PUSH_PROVISIONING_STARTED: generateOpt( + EVENT_NAME.CARD_PUSH_PROVISIONING_STARTED, + ), + CARD_PUSH_PROVISIONING_COMPLETED: generateOpt( + EVENT_NAME.CARD_PUSH_PROVISIONING_COMPLETED, + ), + CARD_PUSH_PROVISIONING_FAILED: generateOpt( + EVENT_NAME.CARD_PUSH_PROVISIONING_FAILED, + ), + CARD_PUSH_PROVISIONING_CANCELED: generateOpt( + EVENT_NAME.CARD_PUSH_PROVISIONING_CANCELED, + ), + CARD_ADD_TO_WALLET_CLICKED: generateOpt( + EVENT_NAME.CARD_ADD_TO_WALLET_CLICKED, + ), CARD_METAL_CHECKOUT_VIEWED: generateOpt( EVENT_NAME.CARD_METAL_CHECKOUT_VIEWED, ), diff --git a/app/core/Engine/controllers/perps-controller/index.test.ts b/app/core/Engine/controllers/perps-controller/index.test.ts index db102d63f79..f31b39e39f5 100644 --- a/app/core/Engine/controllers/perps-controller/index.test.ts +++ b/app/core/Engine/controllers/perps-controller/index.test.ts @@ -121,6 +121,7 @@ describe('perps controller init', () => { initializationState: InitializationState.Uninitialized, initializationError: null, initializationAttempts: 0, + selectedPaymentToken: null, }; initRequestMock.persistedState = { diff --git a/app/core/NotificationManager.js b/app/core/NotificationManager.js index 211a4522691..57bd339e504 100644 --- a/app/core/NotificationManager.js +++ b/app/core/NotificationManager.js @@ -27,6 +27,7 @@ export const SKIP_NOTIFICATION_TRANSACTION_TYPES = [ TransactionType.predictClaim, TransactionType.predictWithdraw, TransactionType.musdConversion, + TransactionType.perpsDepositAndOrder, ]; export const IN_PROGRESS_SKIP_STATUS = [ diff --git a/app/core/redux/slices/cronjobController/index.ts b/app/core/redux/slices/cronjobController/index.ts index 03c7c46fae4..c8f526b966b 100644 --- a/app/core/redux/slices/cronjobController/index.ts +++ b/app/core/redux/slices/cronjobController/index.ts @@ -21,7 +21,6 @@ const slice = createSlice({ state, action: PayloadAction, ) => { - // @ts-expect-error - Extensively deep merge. state.storage = action.payload; }, }, diff --git a/app/images/perps-pay-token-icon.png b/app/images/perps-pay-token-icon.png new file mode 100644 index 00000000000..57784350755 Binary files /dev/null and b/app/images/perps-pay-token-icon.png differ diff --git a/app/selectors/featureFlagController/card/index.test.ts b/app/selectors/featureFlagController/card/index.test.ts index 351d4c92add..a7bb15e8030 100644 --- a/app/selectors/featureFlagController/card/index.test.ts +++ b/app/selectors/featureFlagController/card/index.test.ts @@ -8,6 +8,8 @@ import { selectDisplayCardButtonFeatureFlag, selectCardExperimentalSwitch, selectMetalCardCheckoutFeatureFlag, + selectGalileoAppleWalletProvisioningEnabled, + selectGalileoGoogleWalletProvisioningEnabled, } from '.'; import mockedEngine from '../../../core/__mocks__/MockedEngine'; import { mockedEmptyFlagsState, mockedUndefinedFlagsState } from '../mocks'; @@ -787,3 +789,285 @@ describe('selectMetalCardCheckoutFeatureFlag', () => { expect(result).toBe(false); }); }); + +describe('selectGalileoAppleWalletProvisioningEnabled', () => { + const mockedValidatedVersionGatedFeatureFlag = + validatedVersionGatedFeatureFlag as jest.MockedFunction< + typeof validatedVersionGatedFeatureFlag + >; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns false when feature flag state is empty', () => { + mockedValidatedVersionGatedFeatureFlag.mockReturnValue(undefined); + + const result = selectGalileoAppleWalletProvisioningEnabled( + mockedEmptyFlagsState, + ); + + expect(result).toBe(false); + }); + + it('returns false when RemoteFeatureFlagController state is undefined', () => { + mockedValidatedVersionGatedFeatureFlag.mockReturnValue(undefined); + + const result = selectGalileoAppleWalletProvisioningEnabled( + mockedUndefinedFlagsState, + ); + + expect(result).toBe(false); + }); + + it('returns true when feature flag is enabled and version requirement is met', () => { + mockedValidatedVersionGatedFeatureFlag.mockReturnValue(true); + + const stateWithAppleWalletProvisioning = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + galileoAppleWalletInAppProvisioningEnabled: { + enabled: true, + minimumVersion: '7.0.0', + }, + }, + cacheTimestamp: 0, + }, + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const result = selectGalileoAppleWalletProvisioningEnabled( + stateWithAppleWalletProvisioning, + ); + + expect(result).toBe(true); + expect(mockedValidatedVersionGatedFeatureFlag).toHaveBeenCalledWith({ + enabled: true, + minimumVersion: '7.0.0', + }); + }); + + it('returns false when feature flag is disabled', () => { + mockedValidatedVersionGatedFeatureFlag.mockReturnValue(false); + + const stateWithDisabledFlag = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + galileoAppleWalletInAppProvisioningEnabled: { + enabled: false, + minimumVersion: '7.0.0', + }, + }, + cacheTimestamp: 0, + }, + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const result = selectGalileoAppleWalletProvisioningEnabled( + stateWithDisabledFlag, + ); + + expect(result).toBe(false); + }); + + it('returns false when version requirement is not met', () => { + mockedValidatedVersionGatedFeatureFlag.mockReturnValue(false); + + const stateWithVersionGate = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + galileoAppleWalletInAppProvisioningEnabled: { + enabled: true, + minimumVersion: '99.0.0', + }, + }, + cacheTimestamp: 0, + }, + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const result = + selectGalileoAppleWalletProvisioningEnabled(stateWithVersionGate); + + expect(result).toBe(false); + }); + + it('returns false when validatedVersionGatedFeatureFlag returns undefined', () => { + mockedValidatedVersionGatedFeatureFlag.mockReturnValue(undefined); + + const stateWithMalformedFlag = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + galileoAppleWalletInAppProvisioningEnabled: { + enabled: 'true', // Invalid type + }, + }, + cacheTimestamp: 0, + }, + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const result = selectGalileoAppleWalletProvisioningEnabled( + stateWithMalformedFlag, + ); + + expect(result).toBe(false); + }); +}); + +describe('selectGalileoGoogleWalletProvisioningEnabled', () => { + const mockedValidatedVersionGatedFeatureFlag = + validatedVersionGatedFeatureFlag as jest.MockedFunction< + typeof validatedVersionGatedFeatureFlag + >; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns false when feature flag state is empty', () => { + mockedValidatedVersionGatedFeatureFlag.mockReturnValue(undefined); + + const result = selectGalileoGoogleWalletProvisioningEnabled( + mockedEmptyFlagsState, + ); + + expect(result).toBe(false); + }); + + it('returns false when RemoteFeatureFlagController state is undefined', () => { + mockedValidatedVersionGatedFeatureFlag.mockReturnValue(undefined); + + const result = selectGalileoGoogleWalletProvisioningEnabled( + mockedUndefinedFlagsState, + ); + + expect(result).toBe(false); + }); + + it('returns true when feature flag is enabled and version requirement is met', () => { + mockedValidatedVersionGatedFeatureFlag.mockReturnValue(true); + + const stateWithGoogleWalletProvisioning = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + galileoGoogleWalletInAppProvisioningEnabled: { + enabled: true, + minimumVersion: '7.0.0', + }, + }, + cacheTimestamp: 0, + }, + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const result = selectGalileoGoogleWalletProvisioningEnabled( + stateWithGoogleWalletProvisioning, + ); + + expect(result).toBe(true); + expect(mockedValidatedVersionGatedFeatureFlag).toHaveBeenCalledWith({ + enabled: true, + minimumVersion: '7.0.0', + }); + }); + + it('returns false when feature flag is disabled', () => { + mockedValidatedVersionGatedFeatureFlag.mockReturnValue(false); + + const stateWithDisabledFlag = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + galileoGoogleWalletInAppProvisioningEnabled: { + enabled: false, + minimumVersion: '7.0.0', + }, + }, + cacheTimestamp: 0, + }, + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const result = selectGalileoGoogleWalletProvisioningEnabled( + stateWithDisabledFlag, + ); + + expect(result).toBe(false); + }); + + it('returns false when version requirement is not met', () => { + mockedValidatedVersionGatedFeatureFlag.mockReturnValue(false); + + const stateWithVersionGate = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + galileoGoogleWalletInAppProvisioningEnabled: { + enabled: true, + minimumVersion: '99.0.0', + }, + }, + cacheTimestamp: 0, + }, + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const result = + selectGalileoGoogleWalletProvisioningEnabled(stateWithVersionGate); + + expect(result).toBe(false); + }); + + it('returns false when validatedVersionGatedFeatureFlag returns undefined', () => { + mockedValidatedVersionGatedFeatureFlag.mockReturnValue(undefined); + + const stateWithMalformedFlag = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + galileoGoogleWalletInAppProvisioningEnabled: { + enabled: 'true', // Invalid type + }, + }, + cacheTimestamp: 0, + }, + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const result = selectGalileoGoogleWalletProvisioningEnabled( + stateWithMalformedFlag, + ); + + expect(result).toBe(false); + }); +}); diff --git a/app/selectors/featureFlagController/card/index.ts b/app/selectors/featureFlagController/card/index.ts index 134755674ac..dbd678cc998 100644 --- a/app/selectors/featureFlagController/card/index.ts +++ b/app/selectors/featureFlagController/card/index.ts @@ -254,3 +254,23 @@ export const selectMetalCardCheckoutFeatureFlag = createSelector( return validatedVersionGatedFeatureFlag(remoteFlag) ?? false; }, ); + +export const selectGalileoAppleWalletProvisioningEnabled = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => { + const remoteFlag = + remoteFeatureFlags?.galileoAppleWalletInAppProvisioningEnabled as unknown as GateVersionedFeatureFlag; + + return validatedVersionGatedFeatureFlag(remoteFlag) ?? false; + }, +); + +export const selectGalileoGoogleWalletProvisioningEnabled = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => { + const remoteFlag = + remoteFeatureFlags?.galileoGoogleWalletInAppProvisioningEnabled as unknown as GateVersionedFeatureFlag; + + return validatedVersionGatedFeatureFlag(remoteFlag) ?? false; + }, +); diff --git a/app/util/logs/__snapshots__/index.test.ts.snap b/app/util/logs/__snapshots__/index.test.ts.snap index 28f2fa4fc8f..5a8bc558175 100644 --- a/app/util/logs/__snapshots__/index.test.ts.snap +++ b/app/util/logs/__snapshots__/index.test.ts.snap @@ -514,6 +514,7 @@ exports[`logs :: generateStateLogs Sanitized SeedlessOnboardingController State "optionId": "volume", }, "perpsBalances": {}, + "selectedPaymentToken": null, "tradeConfigurations": { "mainnet": {}, "testnet": {}, @@ -1332,6 +1333,7 @@ exports[`logs :: generateStateLogs generates a valid json export 1`] = ` "optionId": "volume", }, "perpsBalances": {}, + "selectedPaymentToken": null, "tradeConfigurations": { "mainnet": {}, "testnet": {}, diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json index db459cd2eb3..00ff96ddaab 100644 --- a/app/util/test/initial-background-state.json +++ b/app/util/test/initial-background-state.json @@ -507,7 +507,8 @@ "direction": "desc" }, "hip3ConfigVersion": 0, - "perpsBalances": {} + "perpsBalances": {}, + "selectedPaymentToken": null }, "RemoteFeatureFlagController": { "cacheTimestamp": 0, diff --git a/app/util/test/testSetup.js b/app/util/test/testSetup.js index 9606a74681b..4b9b7d962ec 100644 --- a/app/util/test/testSetup.js +++ b/app/util/test/testSetup.js @@ -746,6 +746,13 @@ jest.mock('../../core/Analytics/MetaMetricsTestUtils', () => { }; }); +// Mock whenEngineReady to prevent async Engine access after Jest teardown. +// Components that trigger analytics (trackView/trackEvent) cause the queue to call +// whenEngineReady(), which uses setTimeout and can run after tests finish. +jest.mock('../../core/Analytics/whenEngineReady', () => ({ + whenEngineReady: jest.fn().mockResolvedValue(undefined), +})); + jest.mock('react-native/Libraries/TurboModule/TurboModuleRegistry', () => { const originalModule = jest.requireActual( 'react-native/Libraries/TurboModule/TurboModuleRegistry', diff --git a/bitrise.yml b/bitrise.yml index 50c212289e4..3983181a6bf 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3533,13 +3533,13 @@ app: PROJECT_LOCATION_IOS: ios - opts: is_expand: false - VERSION_NAME: 7.65.0 + VERSION_NAME: 7.66.0 - opts: is_expand: false VERSION_NUMBER: 3607 - opts: is_expand: false - FLASK_VERSION_NAME: 7.65.0 + FLASK_VERSION_NAME: 7.66.0 - opts: is_expand: false FLASK_VERSION_NUMBER: 3607 diff --git a/docs/readme/e2e-testing.md b/docs/readme/e2e-testing.md index 354d61401f6..c5c1073fb34 100644 --- a/docs/readme/e2e-testing.md +++ b/docs/readme/e2e-testing.md @@ -149,15 +149,15 @@ source .e2e.env && yarn test:e2e:android:debug:run **Run Specific Test Folder** ```bash -source .e2e.env && yarn test:e2e:ios:debug:run e2e/specs/your-folder -source .e2e.env && yarn test:e2e:android:debug:run e2e/specs/your-folder +source .e2e.env && yarn test:e2e:ios:debug:run tests/smoke/your-folder +source .e2e.env && yarn test:e2e:android:debug:run tests/smoke/your-folder ``` **Run Specific Test File** ```bash -source .e2e.env && yarn test:e2e:ios:debug:run e2e/specs/onboarding/create-wallet.spec.js -source .e2e.env && yarn test:e2e:android:debug:run e2e/specs/onboarding/create-wallet.spec.js +source .e2e.env && yarn test:e2e:ios:debug:run tests/smoke/onboarding/create-wallet.spec.js +source .e2e.env && yarn test:e2e:android:debug:run tests/smoke/onboarding/create-wallet.spec.js ``` **Run Tests by Tag** @@ -206,8 +206,8 @@ yarn test:e2e:android:flask:run # These commands are hardcoded to build for `flask` build type and `e2e` environment based on the .detoxrc.js file # Run specific Flask test -yarn test:e2e:ios:flask:run e2e/specs/snaps/test-snap-jsx.spec.ts -yarn test:e2e:android:flask:run e2e/specs/snaps/test-snap-jsx.spec.ts +yarn test:e2e:ios:flask:run test/smoke/snaps/test-snap-jsx.spec.ts +yarn test:e2e:android:flask:run tests/smoke/snaps/test-snap-jsx.spec.ts ``` ### Flask Configuration Details @@ -670,7 +670,7 @@ Our CI/CD process is automated through various Bitrise pipelines, each designed - **Example**: ``` - FAIL e2e/specs/swaps/swap-action-smoke.spec.js (232.814 s) + FAIL tests/smoke/swaps/swap-action-smoke.spec.js (232.814 s) SmokeSwaps Swap from Actions ✓ should Swap .05 'ETH' to 'USDT' (90488 ms) ✕ should Swap 100 'USDT' to 'ETH' (50549 ms) diff --git a/e2e/jest.e2e.config.js b/e2e/jest.e2e.config.js index eec7e4b8b50..9b634c21cf6 100644 --- a/e2e/jest.e2e.config.js +++ b/e2e/jest.e2e.config.js @@ -7,10 +7,7 @@ require('dotenv').config({ path: '.e2e.env' }); module.exports = { rootDir: '..', - testMatch: [ - '/e2e/specs/**/*.spec.{js,ts}', - '/tests/**/*.spec.{js,ts}', - ], + testMatch: ['/tests/**/*.spec.{js,ts}'], testTimeout: 300000, maxWorkers: 1, clearMocks: true, diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 27955797e42..47aeb3eadd2 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1319,7 +1319,7 @@ "${inherited}", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.65.0; + MARKETING_VERSION = 7.66.0; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1385,7 +1385,7 @@ "${inherited}", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.65.0; + MARKETING_VERSION = 7.66.0; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1454,7 +1454,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.65.0; + MARKETING_VERSION = 7.66.0; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1518,7 +1518,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.65.0; + MARKETING_VERSION = 7.66.0; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1684,7 +1684,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.65.0; + MARKETING_VERSION = 7.66.0; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = ( "$(inherited)", @@ -1751,7 +1751,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.65.0; + MARKETING_VERSION = 7.66.0; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "$(inherited)", diff --git a/jest.config.js b/jest.config.js index d5fa9d7c7ef..0494537fc27 100644 --- a/jest.config.js +++ b/jest.config.js @@ -51,7 +51,8 @@ const config = { '/app/features/SampleFeature/e2e/', ], testPathIgnorePatterns: [ - '.*/e2e/specs/.*\\.spec\\.(ts|js)$', + '.*/tests/(smoke|regression)/.*\\.spec\\.(ts|js)$', + '.*/e2e/.*\\.spec\\.(ts|js)$', '.*/e2e/pages/', '.*/e2e/selectors/', ], diff --git a/locales/languages/en.json b/locales/languages/en.json index 005a1fecfeb..acf925efabd 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1091,7 +1091,11 @@ "error_toast": "Transaction failed", "error_generic": "Funds have been returned to you", "in_progress": "Adding funds to Perps", - "depositing_your_funds": "Depositing your funds", + "depositing_your_funds": "Setting up your trade", + "deposit_taking_longer": "Deposit taking longer than usual", + "cancel_trade": "Cancel trade", + "trade_canceled": "Trade canceled", + "funds_returned_to_account": "Funds returned to your account", "your_funds_have_arrived": "Your funds have arrived", "estimated_processing_time": "Est. {{time}}", "funds_available_momentarily": "Funds will be available momentarily", @@ -1178,6 +1182,7 @@ "amount_required": "Order amount must be greater than 0", "minimum_amount": "Minimum order size is ${{amount}}", "insufficient_funds": "Insufficient funds", + "insufficient_funds_to_cover_trade": "Insufficient funds to cover the trade", "insufficient_balance": "Insufficient balance. Required: ${{required}}, Available: ${{available}}", "invalid_leverage": "Leverage must be between {{min}}x and {{max}}x", "leverage_below_position": "Leverage must be at least {{required}}x to match your existing position (current: {{provided}}x)", @@ -1660,6 +1665,7 @@ "content": "Trading fees are charged when you open or close a position.", "metamask_fee": "MetaMask fee", "provider_fee": "Provider fee", + "bridge_fee": "Bridge fee", "total": "Total fees", "discount_message": "You're saving {{percentage}}% with MetaMask Rewards." }, @@ -1741,6 +1747,10 @@ "spread": { "title": "Spread", "content": "The spread is the difference between the best bid (highest buy price) and best ask (lowest sell price). A smaller spread indicates higher liquidity." + }, + "pay_with": { + "title": "Pay with", + "content": "Choose which token or balance to use to pay for this trade. You can pay with your Perps balance or select another token from your wallet." } }, "connection": { @@ -3353,7 +3363,11 @@ "terms_apply": "Terms apply.", "ok": "OK", "claim": "Claim", - "processing_claim": "Processing claim..." + "processing_claim": "Processing claim...", + "claim_on_linea_title": "Claim bonuses on Linea", + "claim_on_linea_description": "Your bonus will be issued on Linea, separate from your Ethereum mUSD balance.", + "continue": "Continue", + "unexpected_error": "Unexpected error. Please try again." }, "tron": { "daily_resource_new_energy": "New daily energy", @@ -4705,6 +4719,11 @@ "buy": "Buy {{ticker}}", "on_network": "on {{networkName}}", "debit_card": "Debit card", + "select_payment_method": "Select payment method", + "pay_with": "Pay with", + "buying_via": "Buying via {{providerName}}.", + "change_provider": "Change provider.", + "providers": "Providers", "continue": "Continue", "powered_by_provider": "Powered by {{provider}}", "purchased_currency": "Purchased {{currency}}", @@ -7076,6 +7095,35 @@ "didnt_receive_code": "Didn't receive the code? ", "resend_verification": "Resend it", "resend_cooldown": "Resend available in {{seconds}} seconds" + }, + "push_provisioning": { + "add_to_wallet": "Add to {{walletName}}", + "adding_to_wallet": "Adding to {{walletName}}...", + "continue_setup": "Continue {{walletName}} Setup", + "wallet_not_available": "{{walletName}} not available", + "already_in_wallet": "Already in {{walletName}}", + "success_title": "Card added!", + "success_message": "Your MetaMask Card has been added to {{walletName}}.", + "error_title": "Unable to add card", + "error_wallet_not_available": "{{walletName}} is not available on this device. Please ensure you have {{walletName}} set up.", + "error_wallet_not_initialized": "{{walletName}} is not initialized. Please set up your wallet and try again.", + "error_card_already_in_wallet": "This card is already added to {{walletName}}.", + "error_card_pending": "Your card is being set up in {{walletName}}. Please check back in a few minutes.", + "error_card_suspended": "Your card in {{walletName}} has been suspended. Please contact support for assistance.", + "error_card_not_eligible": "This card is not eligible for mobile wallet provisioning.", + "error_encryption_failed": "Failed to encrypt card data. Please try again.", + "error_invalid_card_data": "Invalid card data. Please verify your card details and try again.", + "error_card_not_found": "Card not found. Please try again.", + "error_card_provider_not_found": "Card provider is not available for your region.", + "error_card_id_mismatch": "Card verification failed. Please try again.", + "error_card_not_active": "Your card is not active. Please activate your card first.", + "error_network": "Network error occurred. Please check your connection and try again.", + "error_timeout": "The request timed out. Please try again.", + "error_server": "Server error occurred. Please try again later.", + "error_unknown": "An unexpected error occurred. Please try again or contact support.", + "error_platform_not_supported": "This platform does not support mobile wallet provisioning.", + "try_again": "Try again", + "cancel": "Cancel" } }, "onboarding_error_fallback": { diff --git a/package.json b/package.json index bb2a87d6c4d..6f1261b92ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metamask", - "version": "7.65.0", + "version": "7.66.0", "private": true, "scripts": { "install:foundryup": "yarn mm-foundryup", @@ -83,7 +83,7 @@ "build:ios:qa": "./scripts/build.sh ios QA", "build:attribution": "./scripts/generate-attributions.sh", "test": "yarn test:unit", - "test:unit": "jest ./app/ ./locales/ ./tests/**/*.test.ts .github/**/*.test.ts --testPathIgnorePatterns='.*/e2e/specs/.*\\.spec\\.(ts|tsx|js)$|.*\\.view(\\..*)?\\.test\\.(ts|tsx|js|jsx)$'", + "test:unit": "jest ./app/ ./locales/ ./tests/**/*.test.ts .github/**/*.test.ts --testPathIgnorePatterns='.*/tests/(smoke|regression)/.*\\.spec\\.(ts|tsx|js)$|.*/e2e/.*\\.spec\\.(ts|tsx|js)$|.*\\.view(\\..*)?\\.test\\.(ts|tsx|js|jsx)$'", "test:view": "jest -c jest.config.view.js --runInBand --no-watchman --verbose --testPathPattern='.*\\.view(\\..*)?\\.test\\.(ts|tsx|js|jsx)$'", "test:unit:update": "time jest -u ./app/", "test:api-specs": "detox reset-lock-file && detox test -c ios.sim.apiSpecs", @@ -184,7 +184,7 @@ "@playwright/test": "^1.57.0", "@metamask/transaction-controller@npm:^61.0.0": "patch:@metamask/transaction-controller@npm%3A62.10.0#~/.yarn/patches/@metamask-transaction-controller-npm-62.10.0-e1d24e7db5.patch", "@metamask/transaction-controller@npm:^62.9.2": "patch:@metamask/transaction-controller@npm%3A62.10.0#~/.yarn/patches/@metamask-transaction-controller-npm-62.10.0-e1d24e7db5.patch", - "@metamask/transaction-controller@npm:^62.11.0": "patch:@metamask/transaction-controller@npm%3A62.10.0#~/.yarn/patches/@metamask-transaction-controller-npm-62.10.0-e1d24e7db5.patch" + "@metamask/transaction-controller@npm:^62.14.0": "patch:@metamask/transaction-controller@npm%3A62.10.0#~/.yarn/patches/@metamask-transaction-controller-npm-62.10.0-e1d24e7db5.patch" }, "dependencies": { "@config-plugins/detox": "^9.0.0", @@ -192,6 +192,7 @@ "@consensys/on-ramp-sdk": "2.1.12", "@craftzdog/react-native-buffer": "^6.1.0", "@ethersproject/abi": "^5.7.0", + "@expensify/react-native-wallet": "^0.1.15", "@expo/fingerprint": "^0.15.0", "@expo/repack-app": "^0.2.9", "@keystonehq/bc-ur-registry-eth": "^0.21.0", @@ -202,13 +203,13 @@ "@metamask/account-api": "^0.12.0", "@metamask/account-tree-controller": "^3.0.0", "@metamask/accounts-controller": "^34.0.0", - "@metamask/address-book-controller": "^7.0.0", + "@metamask/address-book-controller": "^7.0.1", "@metamask/analytics-controller": "^1.0.0", "@metamask/app-metadata-controller": "^2.0.0", "@metamask/approval-controller": "^8.0.0", "@metamask/assets-controllers": "^99.0.0", "@metamask/base-controller": "^9.0.0", - "@metamask/bitcoin-wallet-snap": "^1.9.0", + "@metamask/bitcoin-wallet-snap": "^1.10.0", "@metamask/bridge-controller": "^64.8.0", "@metamask/bridge-status-controller": "^64.4.5", "@metamask/chain-agnostic-permission": "^1.3.0", @@ -267,8 +268,8 @@ "@metamask/preferences-controller": "^21.0.0", "@metamask/preinstalled-example-snap": "^0.7.2", "@metamask/profile-metrics-controller": "^2.0.0", - "@metamask/profile-sync-controller": "^27.0.0", - "@metamask/ramps-controller": "^6.0.0", + "@metamask/profile-sync-controller": "^27.1.0", + "@metamask/ramps-controller": "^7.0.0", "@metamask/react-native-acm": "^1.0.1", "@metamask/react-native-actionsheet": "2.4.2", "@metamask/react-native-button": "^3.0.0", @@ -297,8 +298,8 @@ "@metamask/storage-service": "^1.0.0", "@metamask/swappable-obj-proxy": "^2.1.0", "@metamask/swaps-controller": "^15.0.0", - "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.10.0#~/.yarn/patches/@metamask-transaction-controller-npm-62.10.0-e1d24e7db5.patch", - "@metamask/transaction-pay-controller": "^12.0.2", + "@metamask/transaction-controller": "^62.14.0", + "@metamask/transaction-pay-controller": "^12.1.0", "@metamask/tron-wallet-snap": "^1.19.2", "@metamask/utils": "^11.8.1", "@ngraveio/bc-ur": "^1.1.6", diff --git a/react-native.config.js b/react-native.config.js index 57cae712063..423c01b214a 100644 --- a/react-native.config.js +++ b/react-native.config.js @@ -1,12 +1,32 @@ +/* eslint-disable import/no-commonjs */ // react-native.config.js -// eslint-disable-next-line import/no-commonjs -module.exports = { - dependencies: { - 'react-native-aes-crypto-forked': { - platforms: { - ios: null, // disable Android platform, other platforms will still autolink if provided - }, + +/** + * React Native configuration for autolinking. + * + * NOTE: This is the base configuration. Platform-specific branches will modify this: + * - feat/google-in-app-provisioning: Conditionally enables Android based on SDK presence + * - feat/apple-in-app-provisioning: Enables iOS autolinking for react-native-wallet + */ + +// Build dependencies config +const dependencies = { + 'react-native-aes-crypto-forked': { + platforms: { + ios: null, // disable Android platform, other platforms will still autolink if provided + }, + }, + // Base branch disables wallet library on both platforms + // Platform-specific branches will enable their respective platform + '@expensify/react-native-wallet': { + platforms: { + android: null, + ios: null, }, }, +}; + +module.exports = { + dependencies, // Note: Font assets now managed by expo-font plugin instead of React Native assets }; diff --git a/scripts/run-e2e-tags.sh b/scripts/run-e2e-tags.sh index a53a1fc7853..9ffceafc8be 100755 --- a/scripts/run-e2e-tags.sh +++ b/scripts/run-e2e-tags.sh @@ -4,7 +4,7 @@ set -euo pipefail # Constants -BASE_DIR="./e2e/specs" +BASE_DIR="./tests/smoke" # TEST_SUITE_TAG=".*SmokeEarn.*" echo "Searching for tests with pattern: $TEST_SUITE_TAG" diff --git a/tests/docs/README.md b/tests/docs/README.md index ef1b7787a77..db0dfc3b0e9 100644 --- a/tests/docs/README.md +++ b/tests/docs/README.md @@ -11,7 +11,8 @@ ## E2E Framework Structure -- **Testing Scenarios (`e2e/specs/`)** - Test files organized by feature +- **Regression Testing Scenarios (`e2e/regression/`)** - Regression Test files organized by feature +- **Snoke Testing Scenarios (`e2e/smoke/`)** - Smoke Test files organized by feature - **TypeScript Framework (`tests/framework/`)**: Modern testing framework with type safety - **Legacy JavaScript (`e2e/utils/`)**: Deprecated utilities being migrated - **Page Objects (`e2e/pages/`)**: Page Object Model implementation @@ -30,7 +31,8 @@ **Key E2E Directories:** - `tests/framework/` - TypeScript framework foundation (USE THIS) -- `e2e/specs/` - Test files organized by feature +- `tests/smoke/` - Smoke Test files organized by feature +- `tests/regression/` - Regression Test files organized by feature - `e2e/pages/` - Page Object classes following POM pattern - `e2e/selectors/` - Element selectors (avoid direct use in tests) - `tests/api-mocking/` - API mocking utilities and responses diff --git a/e2e/specs/quarantine/permission-system-delete-wallet.failing.ts b/tests/regression/multichain/permissions/accounts/permission-system-delete-wallet.failing.ts similarity index 69% rename from e2e/specs/quarantine/permission-system-delete-wallet.failing.ts rename to tests/regression/multichain/permissions/accounts/permission-system-delete-wallet.failing.ts index b324fa6eadc..45f443f72f3 100644 --- a/e2e/specs/quarantine/permission-system-delete-wallet.failing.ts +++ b/tests/regression/multichain/permissions/accounts/permission-system-delete-wallet.failing.ts @@ -1,27 +1,30 @@ -import TestHelpers from '../../helpers'; -import { RegressionNetworkAbstractions } from '../../tags'; -import OnboardingView from '../../pages/Onboarding/OnboardingView'; -import ProtectYourWalletView from '../../pages/Onboarding/ProtectYourWalletView'; -import CreatePasswordView from '../../pages/Onboarding/CreatePasswordView'; -import WalletView from '../../pages/wallet/WalletView'; -import Browser from '../../pages/Browser/BrowserView'; -import SettingsView from '../../pages/Settings/SettingsView'; -import TabBarComponent from '../../pages/wallet/TabBarComponent'; -import SkipAccountSecurityModal from '../../pages/Onboarding/SkipAccountSecurityModal'; -import ConnectedAccountsModal from '../../pages/Browser/ConnectedAccountsModal'; -import DeleteWalletModal from '../../pages/Settings/SecurityAndPrivacy/DeleteWalletModal'; -import LoginView from '../../pages/wallet/LoginView'; -import NetworkListModal from '../../pages/Network/NetworkListModal'; -import { loginToApp, navigateToBrowserView } from '../../viewHelper'; -import FixtureBuilder from '../../../tests/framework/fixtures/FixtureBuilder'; -import { withFixtures } from '../../../tests/framework/fixtures/FixtureHelper'; -import MetaMetricsOptIn from '../../pages/Onboarding/MetaMetricsOptInView'; -import ProtectYourWalletModal from '../../pages/Onboarding/ProtectYourWalletModal'; -import OnboardingSuccessView from '../../pages/Onboarding/OnboardingSuccessView'; -import Assertions from '../../../tests/framework/Assertions'; -import ToastModal from '../../pages/wallet/ToastModal'; -import OnboardingSheet from '../../pages/Onboarding/OnboardingSheet'; -import { DappVariants } from '../../../tests/framework/Constants'; +import TestHelpers from '../../../../../e2e/helpers'; +import { RegressionNetworkAbstractions } from '../../../../../e2e/tags'; +import OnboardingView from '../../../../../e2e/pages/Onboarding/OnboardingView'; +import ProtectYourWalletView from '../../../../../e2e/pages/Onboarding/ProtectYourWalletView'; +import CreatePasswordView from '../../../../../e2e/pages/Onboarding/CreatePasswordView'; +import WalletView from '../../../../../e2e/pages/wallet/WalletView'; +import Browser from '../../../../../e2e/pages/Browser/BrowserView'; +import SettingsView from '../../../../../e2e/pages/Settings/SettingsView'; +import TabBarComponent from '../../../../../e2e/pages/wallet/TabBarComponent'; +import SkipAccountSecurityModal from '../../../../../e2e/pages/Onboarding/SkipAccountSecurityModal'; +import ConnectedAccountsModal from '../../../../../e2e/pages/Browser/ConnectedAccountsModal'; +import DeleteWalletModal from '../../../../../e2e/pages/Settings/SecurityAndPrivacy/DeleteWalletModal'; +import LoginView from '../../../../../e2e/pages/wallet/LoginView'; +import NetworkListModal from '../../../../../e2e/pages/Network/NetworkListModal'; +import { + loginToApp, + navigateToBrowserView, +} from '../../../../../e2e/viewHelper'; +import FixtureBuilder from '../../../../framework/fixtures/FixtureBuilder'; +import { withFixtures } from '../../../../framework/fixtures/FixtureHelper'; +import MetaMetricsOptIn from '../../../../../e2e/pages/Onboarding/MetaMetricsOptInView'; +import ProtectYourWalletModal from '../../../../../e2e/pages/Onboarding/ProtectYourWalletModal'; +import OnboardingSuccessView from '../../../../../e2e/pages/Onboarding/OnboardingSuccessView'; +import Assertions from '../../../../framework/Assertions'; +import ToastModal from '../../../../../e2e/pages/wallet/ToastModal'; +import OnboardingSheet from '../../../../../e2e/pages/Onboarding/OnboardingSheet'; +import { DappVariants } from '../../../../framework/Constants'; const SEEDLESS_ONBOARDING_ENABLED = process.env.SEEDLESS_ONBOARDING_ENABLED === 'true'; diff --git a/e2e/specs/quarantine/permission-system-removing-imported-account.failing.ts b/tests/regression/multichain/permissions/accounts/permission-system-removing-imported-account.failing.ts similarity index 76% rename from e2e/specs/quarantine/permission-system-removing-imported-account.failing.ts rename to tests/regression/multichain/permissions/accounts/permission-system-removing-imported-account.failing.ts index 15eee32dd1a..733de5f4eaa 100644 --- a/e2e/specs/quarantine/permission-system-removing-imported-account.failing.ts +++ b/tests/regression/multichain/permissions/accounts/permission-system-removing-imported-account.failing.ts @@ -1,25 +1,25 @@ -import TestHelpers from '../../helpers'; -import { RegressionNetworkAbstractions } from '../../tags'; -import WalletView from '../../pages/wallet/WalletView'; -import ImportAccountView from '../../pages/importAccount/ImportAccountView'; -import TabBarComponent from '../../pages/wallet/TabBarComponent'; +import TestHelpers from '../../../../../e2e/helpers'; +import { RegressionNetworkAbstractions } from '../../../../../e2e/tags'; +import WalletView from '../../../../../e2e/pages/wallet/WalletView'; +import ImportAccountView from '../../../../../e2e/pages/importAccount/ImportAccountView'; +import TabBarComponent from '../../../../../e2e/pages/wallet/TabBarComponent'; -import Browser from '../../pages/Browser/BrowserView'; -import AccountListBottomSheet from '../../pages/wallet/AccountListBottomSheet'; +import Browser from '../../../../../e2e/pages/Browser/BrowserView'; +import AccountListBottomSheet from '../../../../../e2e/pages/wallet/AccountListBottomSheet'; -import ConnectBottomSheet from '../../pages/Browser/ConnectBottomSheet'; -import ConnectedAccountsModal from '../../pages/Browser/ConnectedAccountsModal'; -import NetworkListModal from '../../pages/Network/NetworkListModal'; -import NetworkEducationModal from '../../pages/Network/NetworkEducationModal'; +import ConnectBottomSheet from '../../../../../e2e/pages/Browser/ConnectBottomSheet'; +import ConnectedAccountsModal from '../../../../../e2e/pages/Browser/ConnectedAccountsModal'; +import NetworkListModal from '../../../../../e2e/pages/Network/NetworkListModal'; +import NetworkEducationModal from '../../../../../e2e/pages/Network/NetworkEducationModal'; -import Accounts from '../../../wdio/helpers/Accounts'; +import Accounts from '../../../../../wdio/helpers/Accounts'; import { importWalletWithRecoveryPhrase, navigateToBrowserView, -} from '../../viewHelper'; -import AddAccountBottomSheet from '../../pages/wallet/AddAccountBottomSheet'; -import Assertions from '../../../tests/framework/Assertions'; -import SuccessImportAccountView from '../../pages/importAccount/SuccessImportAccountView'; +} from '../../../../../e2e/viewHelper'; +import AddAccountBottomSheet from '../../../../../e2e/pages/wallet/AddAccountBottomSheet'; +import Assertions from '../../../../framework/Assertions'; +import SuccessImportAccountView from '../../../../../e2e/pages/importAccount/SuccessImportAccountView'; const SEPOLIA = 'Sepolia'; diff --git a/e2e/specs/quarantine/multichain/permissions/chains/multiple-dapps.failing.ts b/tests/regression/multichain/permissions/chains/multiple-dapps.failing.ts similarity index 82% rename from e2e/specs/quarantine/multichain/permissions/chains/multiple-dapps.failing.ts rename to tests/regression/multichain/permissions/chains/multiple-dapps.failing.ts index dd848ed8d5a..836f44ad874 100644 --- a/e2e/specs/quarantine/multichain/permissions/chains/multiple-dapps.failing.ts +++ b/tests/regression/multichain/permissions/chains/multiple-dapps.failing.ts @@ -1,20 +1,23 @@ -import TestHelpers from '../../../../../helpers'; -import { RegressionNetworkExpansion } from '../../../../../tags'; -import Browser from '../../../../../pages/Browser/BrowserView'; -import TabBarComponent from '../../../../../pages/wallet/TabBarComponent'; -import NetworkListModal from '../../../../../pages/Network/NetworkListModal'; -import ConnectedAccountsModal from '../../../../../pages/Browser/ConnectedAccountsModal'; -import FixtureBuilder from '../../../../../../tests/framework/fixtures/FixtureBuilder'; -import { withFixtures } from '../../../../../../tests/framework/fixtures/FixtureHelper'; -import { loginToApp, navigateToBrowserView } from '../../../../../viewHelper'; -import Assertions from '../../../../../../tests/framework/Assertions'; -import WalletView from '../../../../../pages/wallet/WalletView'; -import NetworkNonPemittedBottomSheet from '../../../../../pages/Network/NetworkNonPemittedBottomSheet'; -import NetworkConnectMultiSelector from '../../../../../pages/Browser/NetworkConnectMultiSelector'; -import NetworkEducationModal from '../../../../../pages/Network/NetworkEducationModal'; -import PermissionSummaryBottomSheet from '../../../../../pages/Browser/PermissionSummaryBottomSheet'; -import { DappVariants } from '../../../../../../tests/framework/Constants'; -import TestDApp from '../../../../../pages/Browser/TestDApp'; +import TestHelpers from '../../../../../e2e/helpers'; +import { RegressionNetworkExpansion } from '../../../../../e2e/tags'; +import Browser from '../../../../../e2e/pages/Browser/BrowserView'; +import TabBarComponent from '../../../../../e2e/pages/wallet/TabBarComponent'; +import NetworkListModal from '../../../../../e2e/pages/Network/NetworkListModal'; +import ConnectedAccountsModal from '../../../../../e2e/pages/Browser/ConnectedAccountsModal'; +import FixtureBuilder from '../../../../framework/fixtures/FixtureBuilder'; +import { withFixtures } from '../../../../framework/fixtures/FixtureHelper'; +import { + loginToApp, + navigateToBrowserView, +} from '../../../../../e2e/viewHelper'; +import Assertions from '../../../../framework/Assertions'; +import WalletView from '../../../../../e2e/pages/wallet/WalletView'; +import NetworkNonPemittedBottomSheet from '../../../../../e2e/pages/Network/NetworkNonPemittedBottomSheet'; +import NetworkConnectMultiSelector from '../../../../../e2e/pages/Browser/NetworkConnectMultiSelector'; +import NetworkEducationModal from '../../../../../e2e/pages/Network/NetworkEducationModal'; +import PermissionSummaryBottomSheet from '../../../../../e2e/pages/Browser/PermissionSummaryBottomSheet'; +import { DappVariants } from '../../../../framework/Constants'; +import TestDApp from '../../../../../e2e/pages/Browser/TestDApp'; /* Test Steps: diff --git a/e2e/specs/quarantine/deeplink-to-sell-flow.failing.ts b/tests/regression/ramps/deeplink-to-sell-flow.failing.ts similarity index 77% rename from e2e/specs/quarantine/deeplink-to-sell-flow.failing.ts rename to tests/regression/ramps/deeplink-to-sell-flow.failing.ts index 0e398832f18..a0ccd87b526 100644 --- a/e2e/specs/quarantine/deeplink-to-sell-flow.failing.ts +++ b/tests/regression/ramps/deeplink-to-sell-flow.failing.ts @@ -1,16 +1,16 @@ -import { loginToApp } from '../../viewHelper'; -import FixtureBuilder from '../../../tests/framework/fixtures/FixtureBuilder'; -import { withFixtures } from '../../../tests/framework/fixtures/FixtureHelper'; -import TestHelpers from '../../helpers'; -import SellGetStartedView from '../../pages/Ramps/SellGetStartedView'; -import { RegressionTrade } from '../../tags'; -import BuildQuoteView from '../../pages/Ramps/BuildQuoteView'; -import Assertions from '../../../tests/framework/Assertions'; -import NetworkApprovalBottomSheet from '../../pages/Network/NetworkApprovalBottomSheet'; -import NetworkAddedBottomSheet from '../../pages/Network/NetworkAddedBottomSheet'; -import NetworkEducationModal from '../../pages/Network/NetworkEducationModal'; -import NetworkListModal from '../../pages/Network/NetworkListModal'; -import { PopularNetworksList } from '../../../tests/resources/networks.e2e'; +import { loginToApp } from '../../../e2e/viewHelper'; +import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; +import { withFixtures } from '../../framework/fixtures/FixtureHelper'; +import TestHelpers from '../../../e2e/helpers'; +import SellGetStartedView from '../../../e2e/pages/Ramps/SellGetStartedView'; +import { RegressionTrade } from '../../../e2e/tags'; +import BuildQuoteView from '../../../e2e/pages/Ramps/BuildQuoteView'; +import Assertions from '../../framework/Assertions'; +import NetworkApprovalBottomSheet from '../../../e2e/pages/Network/NetworkApprovalBottomSheet'; +import NetworkAddedBottomSheet from '../../../e2e/pages/Network/NetworkAddedBottomSheet'; +import NetworkEducationModal from '../../../e2e/pages/Network/NetworkEducationModal'; +import NetworkListModal from '../../../e2e/pages/Network/NetworkListModal'; +import { PopularNetworksList } from '../../resources/networks.e2e'; // This test was migrated to the new framework but should be reworked to use withFixtures properly describe(RegressionTrade('Sell Crypto Deeplinks'), () => { diff --git a/e2e/specs/quarantine/swap-action-regression.failing.ts b/tests/regression/swap/swap-action-regression.failing.ts similarity index 69% rename from e2e/specs/quarantine/swap-action-regression.failing.ts rename to tests/regression/swap/swap-action-regression.failing.ts index 2071a8f608a..b1c1b7abbd4 100644 --- a/e2e/specs/quarantine/swap-action-regression.failing.ts +++ b/tests/regression/swap/swap-action-regression.failing.ts @@ -1,18 +1,18 @@ -import { withFixtures } from '../../../tests/framework/fixtures/FixtureHelper'; -import { LocalNode, LocalNodeType } from '../../../tests/framework/types'; -import FixtureBuilder from '../../../tests/framework/fixtures/FixtureBuilder'; -import TabBarComponent from '../../pages/wallet/TabBarComponent'; -import WalletView from '../../pages/wallet/WalletView'; -import { RegressionTrade } from '../../tags'; +import { withFixtures } from '../../framework/fixtures/FixtureHelper'; +import { LocalNode, LocalNodeType } from '../../framework/types'; +import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; +import TabBarComponent from '../../../e2e/pages/wallet/TabBarComponent'; +import WalletView from '../../../e2e/pages/wallet/WalletView'; +import { RegressionTrade } from '../../../e2e/tags'; import { submitSwapUnifiedUI, checkSwapActivity, -} from '../../../tests/helpers/swap/swap-unified-ui'; -import { loginToApp } from '../../viewHelper'; -import { prepareSwapsTestEnvironment } from '../../../tests/helpers/swap/prepareSwapsTestEnvironment'; -import { testSpecificMock } from '../../../tests/helpers/swap/swap-mocks'; -import { AnvilPort } from '../../../tests/framework/fixtures/FixtureUtils'; -import { AnvilManager } from '../../../tests/seeder/anvil-manager'; +} from '../../helpers/swap/swap-unified-ui'; +import { loginToApp } from '../../../e2e/viewHelper'; +import { prepareSwapsTestEnvironment } from '../../helpers/swap/prepareSwapsTestEnvironment'; +import { testSpecificMock } from '../../helpers/swap/swap-mocks'; +import { AnvilPort } from '../../framework/fixtures/FixtureUtils'; +import { AnvilManager } from '../../seeder/anvil-manager'; describe(RegressionTrade('Multiple Swaps from Actions'), (): void => { beforeEach(async (): Promise => { diff --git a/e2e/specs/quarantine/deeplinks.failing.ts b/tests/regression/wallet/deeplinks.failing.ts similarity index 85% rename from e2e/specs/quarantine/deeplinks.failing.ts rename to tests/regression/wallet/deeplinks.failing.ts index 8d523d8493a..43818196001 100644 --- a/e2e/specs/quarantine/deeplinks.failing.ts +++ b/tests/regression/wallet/deeplinks.failing.ts @@ -1,21 +1,21 @@ -import TestHelpers from '../../helpers'; -import { RegressionWalletPlatform } from '../../tags'; -import ConnectBottomSheet from '../../pages/Browser/ConnectBottomSheet'; -import NetworkApprovalBottomSheet from '../../pages/Network/NetworkApprovalBottomSheet'; -import NetworkAddedBottomSheet from '../../pages/Network/NetworkAddedBottomSheet'; -import Browser from '../../pages/Browser/BrowserView'; -import NetworkView from '../../pages/Settings/NetworksView'; -import SettingsView from '../../pages/Settings/SettingsView'; -import LoginView from '../../pages/wallet/LoginView'; -import TransactionConfirmationView from '../../pages/Send/TransactionConfirmView'; -import SecurityAndPrivacy from '../../pages/Settings/SecurityAndPrivacy/SecurityAndPrivacyView'; -import CommonView from '../../pages/CommonView'; -import WalletView from '../../pages/wallet/WalletView'; -import { importWalletWithRecoveryPhrase } from '../../viewHelper'; +import TestHelpers from '../../../e2e/helpers'; +import { RegressionWalletPlatform } from '../../../e2e/tags'; +import ConnectBottomSheet from '../../../e2e/pages/Browser/ConnectBottomSheet'; +import NetworkApprovalBottomSheet from '../../../e2e/pages/Network/NetworkApprovalBottomSheet'; +import NetworkAddedBottomSheet from '../../../e2e/pages/Network/NetworkAddedBottomSheet'; +import Browser from '../../../e2e/pages/Browser/BrowserView'; +import NetworkView from '../../../e2e/pages/Settings/NetworksView'; +import SettingsView from '../../../e2e/pages/Settings/SettingsView'; +import LoginView from '../../../e2e/pages/wallet/LoginView'; +import TransactionConfirmationView from '../../../e2e/pages/Send/TransactionConfirmView'; +import SecurityAndPrivacy from '../../../e2e/pages/Settings/SecurityAndPrivacy/SecurityAndPrivacyView'; +import CommonView from '../../../e2e/pages/CommonView'; +import WalletView from '../../../e2e/pages/wallet/WalletView'; +import { importWalletWithRecoveryPhrase } from '../../../e2e/viewHelper'; import Accounts from '../../../wdio/helpers/Accounts'; -import TabBarComponent from '../../pages/wallet/TabBarComponent'; -import Assertions from '../../../tests/framework/Assertions'; -import { PopularNetworksList } from '../../../tests/resources/networks.e2e'; +import TabBarComponent from '../../../e2e/pages/wallet/TabBarComponent'; +import Assertions from '../../framework/Assertions'; +import { PopularNetworksList } from '../../resources/networks.e2e'; //const BINANCE_RPC_URL = 'https://bsc-dataseed1.binance.org'; diff --git a/e2e/specs/quarantine/start-exploring.failing.ts b/tests/regression/wallet/start-exploring.failing.ts similarity index 73% rename from e2e/specs/quarantine/start-exploring.failing.ts rename to tests/regression/wallet/start-exploring.failing.ts index 0817264b7ca..72c4ad8b744 100644 --- a/e2e/specs/quarantine/start-exploring.failing.ts +++ b/tests/regression/wallet/start-exploring.failing.ts @@ -1,13 +1,13 @@ -import { RegressionWalletPlatform } from '../../tags'; -import TestHelpers from '../../helpers'; -import OnboardingView from '../../pages/Onboarding/OnboardingView'; -import OnboardingCarouselView from '../../pages/Onboarding/OnboardingCarouselView'; -import ProtectYourWalletView from '../../pages/Onboarding/ProtectYourWalletView'; -import CreatePasswordView from '../../pages/Onboarding/CreatePasswordView'; -import OnboardingSuccessView from '../../pages/Onboarding/OnboardingSuccessView'; -import SkipAccountSecurityModal from '../../pages/Onboarding/SkipAccountSecurityModal'; -import { acceptTermOfUse } from '../../viewHelper'; -import Assertions from '../../../tests/framework/Assertions'; +import { RegressionWalletPlatform } from '../../../e2e/tags'; +import TestHelpers from '../../../e2e/helpers'; +import OnboardingView from '../../../e2e/pages/Onboarding/OnboardingView'; +import OnboardingCarouselView from '../../../e2e/pages/Onboarding/OnboardingCarouselView'; +import ProtectYourWalletView from '../../../e2e/pages/Onboarding/ProtectYourWalletView'; +import CreatePasswordView from '../../../e2e/pages/Onboarding/CreatePasswordView'; +import OnboardingSuccessView from '../../../e2e/pages/Onboarding/OnboardingSuccessView'; +import SkipAccountSecurityModal from '../../../e2e/pages/Onboarding/SkipAccountSecurityModal'; +import { acceptTermOfUse } from '../../../e2e/viewHelper'; +import Assertions from '../../framework/Assertions'; const PASSWORD = '12345678'; diff --git a/e2e/specs/quarantine/token-details.failing.ts b/tests/regression/wallet/token-details.failing.ts similarity index 84% rename from e2e/specs/quarantine/token-details.failing.ts rename to tests/regression/wallet/token-details.failing.ts index ae2b3afdea2..7cdb8f507d0 100644 --- a/e2e/specs/quarantine/token-details.failing.ts +++ b/tests/regression/wallet/token-details.failing.ts @@ -1,13 +1,13 @@ -import { RegressionTrade } from '../../tags'; -import WalletView from '../../pages/wallet/WalletView'; -import TokenOverview from '../../pages/wallet/TokenOverview'; +import { RegressionTrade } from '../../../e2e/tags'; +import WalletView from '../../../e2e/pages/wallet/WalletView'; +import TokenOverview from '../../../e2e/pages/wallet/TokenOverview'; import { importWalletWithRecoveryPhrase, switchToSepoliaNetwork, -} from '../../viewHelper'; -import Assertions from '../../../tests/framework/Assertions'; -import CommonView from '../../pages/CommonView'; -import TestHelpers from '../../helpers'; +} from '../../../e2e/viewHelper'; +import Assertions from '../../framework/Assertions'; +import CommonView from '../../../e2e/pages/CommonView'; +import TestHelpers from '../../../e2e/helpers'; // This test was migrated to the new framework but should be reworked to use withFixtures properly describe(RegressionTrade('Token Chart Tests'), () => { diff --git a/e2e/specs/quarantine/create-wallet-account.failing.ts b/tests/smoke/accounts/create-wallet-account.failing.ts similarity index 80% rename from e2e/specs/quarantine/create-wallet-account.failing.ts rename to tests/smoke/accounts/create-wallet-account.failing.ts index 1dbec5e4bac..c3106f99022 100644 --- a/e2e/specs/quarantine/create-wallet-account.failing.ts +++ b/tests/smoke/accounts/create-wallet-account.failing.ts @@ -1,12 +1,12 @@ -import { SmokeAccounts } from '../../tags.js'; -import WalletView from '../../pages/wallet/WalletView.js'; -import AccountListBottomSheet from '../../pages/wallet/AccountListBottomSheet.js'; -import Assertions from '../../../tests/framework/Assertions.js'; -import { withMultichainAccountDetailsV2EnabledFixtures } from '../../../tests/helpers/multichain-accounts/common.js'; -import AccountDetails from '../../pages/MultichainAccounts/AccountDetails.js'; -import AddressList from '../../pages/MultichainAccounts/AddressList.js'; -import { defaultGanacheOptions } from '../../../tests/framework/Constants.js'; -import { completeSrpQuiz } from '../../../tests/flows/accounts.flow.js'; +import { SmokeAccounts } from '../../../e2e/tags.js'; +import WalletView from '../../../e2e/pages/wallet/WalletView.js'; +import AccountListBottomSheet from '../../../e2e/pages/wallet/AccountListBottomSheet.js'; +import Assertions from '../../framework/Assertions.js'; +import { withMultichainAccountDetailsV2EnabledFixtures } from '../../helpers/multichain-accounts/common.js'; +import AccountDetails from '../../../e2e/pages/MultichainAccounts/AccountDetails.js'; +import AddressList from '../../../e2e/pages/MultichainAccounts/AddressList.js'; +import { defaultGanacheOptions } from '../../framework/Constants.js'; +import { completeSrpQuiz } from '../../flows/accounts.flow.js'; // Quarantining, See open ticket here: https://github.com/MetaMask/metamask-mobile/issues/21429 describe(SmokeAccounts('Create wallet accounts'), () => { diff --git a/e2e/specs/quarantine/permission-system-remove.failing.ts b/tests/smoke/multichain/permissions/accounts/permission-system-remove.failing.ts similarity index 57% rename from e2e/specs/quarantine/permission-system-remove.failing.ts rename to tests/smoke/multichain/permissions/accounts/permission-system-remove.failing.ts index bd7abdd5be4..7c6fbb706cf 100644 --- a/e2e/specs/quarantine/permission-system-remove.failing.ts +++ b/tests/smoke/multichain/permissions/accounts/permission-system-remove.failing.ts @@ -1,24 +1,27 @@ -import TestHelpers from '../../helpers'; -import { SmokeNetworkAbstractions } from '../../tags'; -import Browser from '../../pages/Browser/BrowserView'; -import TabBarComponent from '../../pages/wallet/TabBarComponent'; +import TestHelpers from '../../../../../e2e/helpers'; +import { SmokeNetworkAbstractions } from '../../../../../e2e/tags'; +import Browser from '../../../../../e2e/pages/Browser/BrowserView'; +import TabBarComponent from '../../../../../e2e/pages/wallet/TabBarComponent'; -import FixtureBuilder from '../../../tests/framework/fixtures/FixtureBuilder'; -import { withFixtures } from '../../../tests/framework/fixtures/FixtureHelper'; -import { loginToApp, navigateToBrowserView } from '../../viewHelper'; -import Assertions from '../../../tests/framework/Assertions'; +import FixtureBuilder from '../../../../framework/fixtures/FixtureBuilder'; +import { withFixtures } from '../../../../framework/fixtures/FixtureHelper'; +import { + loginToApp, + navigateToBrowserView, +} from '../../../../../e2e/viewHelper'; +import Assertions from '../../../../framework/Assertions'; -import { PopularNetworksList } from '../../../tests/resources/networks.e2e'; +import { PopularNetworksList } from '../../../../resources/networks.e2e'; -import WalletView from '../../pages/wallet/WalletView'; -import NetworkListModal from '../../pages/Network/NetworkListModal'; -import TestDApp from '../../pages/Browser/TestDApp'; -import ConnectBottomSheet from '../../pages/Browser/ConnectBottomSheet'; -import PermissionSummaryBottomSheet from '../../pages/Browser/PermissionSummaryBottomSheet'; -import NetworkConnectMultiSelector from '../../pages/Browser/NetworkConnectMultiSelector'; -import NetworkNonPemittedBottomSheet from '../../pages/Network/NetworkNonPemittedBottomSheet'; -import ConnectedAccountsModal from '../../pages/Browser/ConnectedAccountsModal'; -import { DappVariants } from '../../../tests/framework/Constants'; +import WalletView from '../../../../../e2e/pages/wallet/WalletView'; +import NetworkListModal from '../../../../../e2e/pages/Network/NetworkListModal'; +import TestDApp from '../../../../../e2e/pages/Browser/TestDApp'; +import ConnectBottomSheet from '../../../../../e2e/pages/Browser/ConnectBottomSheet'; +import PermissionSummaryBottomSheet from '../../../../../e2e/pages/Browser/PermissionSummaryBottomSheet'; +import NetworkConnectMultiSelector from '../../../../../e2e/pages/Browser/NetworkConnectMultiSelector'; +import NetworkNonPemittedBottomSheet from '../../../../../e2e/pages/Network/NetworkNonPemittedBottomSheet'; +import ConnectedAccountsModal from '../../../../../e2e/pages/Browser/ConnectedAccountsModal'; +import { DappVariants } from '../../../../framework/Constants'; // This test was migrated to the new framework but should be reworked to use withFixtures properly describe(SmokeNetworkAbstractions('Chain Permission Management'), () => { diff --git a/e2e/specs/quarantine/permission-system-update-permissions.failing.ts b/tests/smoke/multichain/permissions/accounts/permission-system-update-permissions.failing.ts similarity index 84% rename from e2e/specs/quarantine/permission-system-update-permissions.failing.ts rename to tests/smoke/multichain/permissions/accounts/permission-system-update-permissions.failing.ts index d386d24e43f..b578dfaa018 100644 --- a/e2e/specs/quarantine/permission-system-update-permissions.failing.ts +++ b/tests/smoke/multichain/permissions/accounts/permission-system-update-permissions.failing.ts @@ -1,21 +1,24 @@ -import { SmokeNetworkAbstractions } from '../../tags'; -import Browser from '../../pages/Browser/BrowserView'; -import ConnectedAccountsModal from '../../pages/Browser/ConnectedAccountsModal'; -import { loginToApp, navigateToBrowserView } from '../../viewHelper'; -import Assertions from '../../../tests/framework/Assertions'; -import NetworkConnectMultiSelector from '../../pages/Browser/NetworkConnectMultiSelector'; -import NetworkNonPemittedBottomSheet from '../../pages/Network/NetworkNonPemittedBottomSheet'; -import { CustomNetworks } from '../../../tests/resources/networks.e2e'; -import PermissionSummaryBottomSheet from '../../pages/Browser/PermissionSummaryBottomSheet'; -import { NetworkNonPemittedBottomSheetSelectorsText } from '../../../app/components/Views/NetworkConnect/NetworkNonPemittedBottomSheet.testIds'; -import NetworkListModal from '../../pages/Network/NetworkListModal'; -import ToastModal from '../../pages/wallet/ToastModal'; -import AccountListBottomSheet from '../../pages/wallet/AccountListBottomSheet'; -import AddNewAccountSheet from '../../pages/wallet/AddNewAccountSheet'; -import FixtureBuilder from '../../../tests/framework/fixtures/FixtureBuilder'; -import { withFixtures } from '../../../tests/framework/fixtures/FixtureHelper'; -import { DappVariants } from '../../../tests/framework/Constants'; -import { logger } from '../../../tests/framework/logger'; +import { SmokeNetworkAbstractions } from '../../../../../e2e/tags'; +import Browser from '../../../../../e2e/pages/Browser/BrowserView'; +import ConnectedAccountsModal from '../../../../../e2e/pages/Browser/ConnectedAccountsModal'; +import { + loginToApp, + navigateToBrowserView, +} from '../../../../../e2e/viewHelper'; +import Assertions from '../../../../framework/Assertions'; +import NetworkConnectMultiSelector from '../../../../../e2e/pages/Browser/NetworkConnectMultiSelector'; +import NetworkNonPemittedBottomSheet from '../../../../../e2e/pages/Network/NetworkNonPemittedBottomSheet'; +import { CustomNetworks } from '../../../../resources/networks.e2e'; +import PermissionSummaryBottomSheet from '../../../../../e2e/pages/Browser/PermissionSummaryBottomSheet'; +import { NetworkNonPemittedBottomSheetSelectorsText } from '../../../../../app/components/Views/NetworkConnect/NetworkNonPemittedBottomSheet.testIds'; +import NetworkListModal from '../../../../../e2e/pages/Network/NetworkListModal'; +import ToastModal from '../../../../../e2e/pages/wallet/ToastModal'; +import AccountListBottomSheet from '../../../../../e2e/pages/wallet/AccountListBottomSheet'; +import AddNewAccountSheet from '../../../../../e2e/pages/wallet/AddNewAccountSheet'; +import FixtureBuilder from '../../../../framework/fixtures/FixtureBuilder'; +import { withFixtures } from '../../../../framework/fixtures/FixtureHelper'; +import { DappVariants } from '../../../../framework/Constants'; +import { logger } from '../../../../framework/logger'; const accountOneText = 'Account 1'; const accountTwoText = 'Account 2'; diff --git a/e2e/specs/quarantine/multichain/permissions/chains/permission-system-add-non-permitted.failing.js b/tests/smoke/multichain/permissions/chains/permission-system-add-non-permitted.failing.js similarity index 83% rename from e2e/specs/quarantine/multichain/permissions/chains/permission-system-add-non-permitted.failing.js rename to tests/smoke/multichain/permissions/chains/permission-system-add-non-permitted.failing.js index 5ea20cf251a..5e2ef666af1 100644 --- a/e2e/specs/quarantine/multichain/permissions/chains/permission-system-add-non-permitted.failing.js +++ b/tests/smoke/multichain/permissions/chains/permission-system-add-non-permitted.failing.js @@ -1,23 +1,26 @@ import { RegressionNetworkExpansion, SmokeNetworkExpansion, -} from '../../../../../tags'; -import { loginToApp, navigateToBrowserView } from '../../../../../viewHelper'; -import Assertions from '../../../../../../tests/framework/Assertions'; -import TestHelpers from '../../../../../helpers'; -import FixtureBuilder from '../../../../../../tests/framework/fixtures/FixtureBuilder'; -import { withFixtures } from '../../../../../../tests/framework/fixtures/FixtureHelper'; -import { CustomNetworks } from '../../../../../../tests/resources/networks.e2e'; -import Browser from '../../../../../pages/Browser/BrowserView'; -import TabBarComponent from '../../../../../pages/wallet/TabBarComponent'; -import { NetworkNonPemittedBottomSheetSelectorsText } from '../../../../../../app/components/Views/NetworkConnect/NetworkNonPemittedBottomSheet.testIds'; -import ConnectedAccountsModal from '../../../../../pages/Browser/ConnectedAccountsModal'; -import NetworkConnectMultiSelector from '../../../../../pages/Browser/NetworkConnectMultiSelector'; -import { DappVariants } from '../../../../../../tests/framework/Constants'; -import WalletView from '../../../../../pages/wallet/WalletView'; -import NetworkListModal from '../../../../../pages/Network/NetworkListModal'; -import TestDApp from '../../../../../pages/Browser/TestDApp'; -import ConnectBottomSheet from '../../../../../pages/Browser/ConnectBottomSheet'; +} from '../../../../../e2e/tags'; +import { + loginToApp, + navigateToBrowserView, +} from '../../../../../e2e/viewHelper'; +import Assertions from '../../../../framework/Assertions'; +import TestHelpers from '../../../../../e2e/helpers'; +import FixtureBuilder from '../../../../framework/fixtures/FixtureBuilder'; +import { withFixtures } from '../../../../framework/fixtures/FixtureHelper'; +import { CustomNetworks } from '../../../../resources/networks.e2e'; +import Browser from '../../../../../e2e/pages/Browser/BrowserView'; +import TabBarComponent from '../../../../../e2e/pages/wallet/TabBarComponent'; +import { NetworkNonPemittedBottomSheetSelectorsText } from '../../../../../app/components/Views/NetworkConnect/NetworkNonPemittedBottomSheet.testIds'; +import ConnectedAccountsModal from '../../../../../e2e/pages/Browser/ConnectedAccountsModal'; +import NetworkConnectMultiSelector from '../../../../../e2e/pages/Browser/NetworkConnectMultiSelector'; +import { DappVariants } from '../../../../framework/Constants'; +import WalletView from '../../../../../e2e/pages/wallet/WalletView'; +import NetworkListModal from '../../../../../e2e/pages/Network/NetworkListModal'; +import TestDApp from '../../../../../e2e/pages/Browser/TestDApp'; +import ConnectBottomSheet from '../../../../../e2e/pages/Browser/ConnectBottomSheet'; const SEPOLIA = CustomNetworks.Sepolia.providerConfig.nickname; const ETHEREUM_MAIN_NET_NETWORK_NAME = diff --git a/e2e/specs/quarantine/solana-send-flow.failing.ts b/tests/smoke/multichain/solana-send-flow.failing.ts similarity index 76% rename from e2e/specs/quarantine/solana-send-flow.failing.ts rename to tests/smoke/multichain/solana-send-flow.failing.ts index 85411172814..d1c6ff1764d 100644 --- a/e2e/specs/quarantine/solana-send-flow.failing.ts +++ b/tests/smoke/multichain/solana-send-flow.failing.ts @@ -1,15 +1,15 @@ -import { SmokeNetworkExpansion } from '../../tags'; -import { importWalletWithRecoveryPhrase } from '../../viewHelper'; -import Assertions from '../../../tests/framework/Assertions'; -import TestHelpers from '../../helpers'; -import TabBarComponent from '../../pages/wallet/TabBarComponent'; -import WalletView from '../../pages/wallet/WalletView'; -import ActivitiesView from '../../pages/Transactions/ActivitiesView'; -import SnapSendActionSheet from '../../pages/wallet/SendActionBottomSheet'; -import NetworkEducationModal from '../../pages/Network/NetworkEducationModal'; -import AccountListBottomSheet from '../../pages/wallet/AccountListBottomSheet'; -import AddAccountBottomSheet from '../../pages/wallet/AddAccountBottomSheet'; -import AddNewHdAccountComponent from '../../pages/wallet/MultiSrp/AddAccountToSrp/AddNewHdAccountComponent'; +import { SmokeNetworkExpansion } from '../../../e2e/tags'; +import { importWalletWithRecoveryPhrase } from '../../../e2e/viewHelper'; +import Assertions from '../../framework/Assertions'; +import TestHelpers from '../../../e2e/helpers'; +import TabBarComponent from '../../../e2e/pages/wallet/TabBarComponent'; +import WalletView from '../../../e2e/pages/wallet/WalletView'; +import ActivitiesView from '../../../e2e/pages/Transactions/ActivitiesView'; +import SnapSendActionSheet from '../../../e2e/pages/wallet/SendActionBottomSheet'; +import NetworkEducationModal from '../../../e2e/pages/Network/NetworkEducationModal'; +import AccountListBottomSheet from '../../../e2e/pages/wallet/AccountListBottomSheet'; +import AddAccountBottomSheet from '../../../e2e/pages/wallet/AddAccountBottomSheet'; +import AddNewHdAccountComponent from '../../../e2e/pages/wallet/MultiSrp/AddAccountToSrp/AddNewHdAccountComponent'; // Test constants const INVALID_ADDRESS = 'invalid address'; diff --git a/e2e/specs/quarantine/wallet-invokeMethod.failing.ts b/tests/smoke/multichain/solana-wallet-standard/wallet-invokeMethod.failing.ts similarity index 68% rename from e2e/specs/quarantine/wallet-invokeMethod.failing.ts rename to tests/smoke/multichain/solana-wallet-standard/wallet-invokeMethod.failing.ts index 5185c4c06f2..67344574de9 100644 --- a/e2e/specs/quarantine/wallet-invokeMethod.failing.ts +++ b/tests/smoke/multichain/solana-wallet-standard/wallet-invokeMethod.failing.ts @@ -2,21 +2,21 @@ * E2E tests for Solana methods using Multichain API */ import { SolScope } from '@metamask/keyring-api'; -import TestHelpers from '../../helpers'; -import { SmokeMultiChainAPI } from '../../tags'; -import Browser from '../../pages/Browser/BrowserView'; -import FixtureBuilder from '../../../tests/framework/fixtures/FixtureBuilder'; -import { withFixtures } from '../../../tests/framework/fixtures/FixtureHelper'; -import { loginToApp, navigateToBrowserView } from '../../viewHelper'; -import Assertions from '../../../tests/framework/Assertions'; -import MultichainTestDApp from '../../pages/Browser/MultichainTestDApp'; -import AddNewHdAccountComponent from '../../pages/wallet/MultiSrp/AddAccountToSrp/AddNewHdAccountComponent'; -import Gestures from '../../../tests/framework/Gestures'; -import Matchers from '../../../tests/framework/Matchers'; -import WalletView from '../../pages/wallet/WalletView'; -import AccountListBottomSheet from '../../pages/wallet/AccountListBottomSheet'; -import AddAccountBottomSheet from '../../pages/wallet/AddAccountBottomSheet'; -import { DappVariants } from '../../../tests/framework/Constants'; +import TestHelpers from '../../../../e2e/helpers'; +import { SmokeMultiChainAPI } from '../../../../e2e/tags'; +import Browser from '../../../../e2e/pages/Browser/BrowserView'; +import FixtureBuilder from '../../../framework/fixtures/FixtureBuilder'; +import { withFixtures } from '../../../framework/fixtures/FixtureHelper'; +import { loginToApp, navigateToBrowserView } from '../../../../e2e/viewHelper'; +import Assertions from '../../../framework/Assertions'; +import MultichainTestDApp from '../../../../e2e/pages/Browser/MultichainTestDApp'; +import AddNewHdAccountComponent from '../../../../e2e/pages/wallet/MultiSrp/AddAccountToSrp/AddNewHdAccountComponent'; +import Gestures from '../../../framework/Gestures'; +import Matchers from '../../../framework/Matchers'; +import WalletView from '../../../../e2e/pages/wallet/WalletView'; +import AccountListBottomSheet from '../../../../e2e/pages/wallet/AccountListBottomSheet'; +import AddAccountBottomSheet from '../../../../e2e/pages/wallet/AddAccountBottomSheet'; +import { DappVariants } from '../../../framework/Constants'; const SOLANA_MAINNET_CHAIN_ID = SolScope.Mainnet; diff --git a/e2e/specs/quarantine/multichain/wallet-invokeMethod.failing.ts b/tests/smoke/multichain/wallet-invokeMethod.failing.ts similarity index 94% rename from e2e/specs/quarantine/multichain/wallet-invokeMethod.failing.ts rename to tests/smoke/multichain/wallet-invokeMethod.failing.ts index eaad814251b..c19c691a118 100644 --- a/e2e/specs/quarantine/multichain/wallet-invokeMethod.failing.ts +++ b/tests/smoke/multichain/wallet-invokeMethod.failing.ts @@ -18,23 +18,23 @@ * 2. Result elements are created in the DOM * 3. Results appear in the expected format (truncated vs non-truncated) */ -import { SmokeMultiChainAPI } from '../../../tags'; -import FixtureBuilder from '../../../../tests/framework/fixtures/FixtureBuilder'; -import { withFixtures } from '../../../../tests/framework/fixtures/FixtureHelper'; -import MultichainTestDApp from '../../../pages/Browser/MultichainTestDApp'; -import { BrowserViewSelectorsIDs } from '../../../../app/components/Views/BrowserTab/BrowserView.testIds'; -import MultichainUtilities from '../../../utils/MultichainUtilities'; -import Assertions from '../../../../tests/framework/Assertions'; -import { MULTICHAIN_TEST_TIMEOUTS } from '../../../selectors/Browser/MultichainTestDapp.selectors'; +import { SmokeMultiChainAPI } from '../../../e2e/tags'; +import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; +import { withFixtures } from '../../framework/fixtures/FixtureHelper'; +import MultichainTestDApp from '../../../e2e/pages/Browser/MultichainTestDApp'; +import { BrowserViewSelectorsIDs } from '../../../app/components/Views/BrowserTab/BrowserView.testIds'; +import MultichainUtilities from '../../../e2e/utils/MultichainUtilities'; +import Assertions from '../../framework/Assertions'; +import { MULTICHAIN_TEST_TIMEOUTS } from '../../../e2e/selectors/Browser/MultichainTestDapp.selectors'; import { waitFor } from 'detox'; -import FooterActions from '../../../pages/Browser/Confirmations/FooterActions'; +import FooterActions from '../../../e2e/pages/Browser/Confirmations/FooterActions'; import { isHexString } from '@metamask/utils'; -import { DappVariants } from '../../../../tests/framework/Constants'; -import { LocalNodeType } from '../../../../tests/framework'; -import { AnvilNodeOptions } from '../../../../tests/framework/types'; +import { DappVariants } from '../../framework/Constants'; +import { LocalNodeType } from '../../framework'; +import { AnvilNodeOptions } from '../../framework/types'; import { Mockttp } from 'mockttp'; -import { setupRemoteFeatureFlagsMock } from '../../../../tests/api-mocking/helpers/remoteFeatureFlagsHelper'; -import { remoteFeatureEip7702 } from '../../../../tests/api-mocking/mock-responses/feature-flags-mocks'; +import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; +import { remoteFeatureEip7702 } from '../../api-mocking/mock-responses/feature-flags-mocks'; const ANVIL_NODE_OPTIONS_WITH_GATOR = [ { diff --git a/e2e/specs/quarantine/wallet-notify.failing.ts b/tests/smoke/multichain/wallet-notify.failing.ts similarity index 87% rename from e2e/specs/quarantine/wallet-notify.failing.ts rename to tests/smoke/multichain/wallet-notify.failing.ts index 0cb58bdfcaa..4eb8dc90566 100644 --- a/e2e/specs/quarantine/wallet-notify.failing.ts +++ b/tests/smoke/multichain/wallet-notify.failing.ts @@ -27,13 +27,13 @@ * 4. Infer state by checking presence/absence of specific elements * 5. Check for state changes (empty → has notifications) to prove functionality */ -import { SmokeMultiChainAPI } from '../../tags'; -import FixtureBuilder from '../../../tests/framework/fixtures/FixtureBuilder'; -import { withFixtures } from '../../../tests/framework/fixtures/FixtureHelper'; -import MultichainTestDApp from '../../pages/Browser/MultichainTestDApp'; -import MultichainUtilities from '../../utils/MultichainUtilities'; -import Assertions from '../../../tests/framework/Assertions'; -import { DappVariants } from '../../../tests/framework/Constants'; +import { SmokeMultiChainAPI } from '../../../e2e/tags'; +import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; +import { withFixtures } from '../../framework/fixtures/FixtureHelper'; +import MultichainTestDApp from '../../../e2e/pages/Browser/MultichainTestDApp'; +import MultichainUtilities from '../../../e2e/utils/MultichainUtilities'; +import Assertions from '../../framework/Assertions'; +import { DappVariants } from '../../framework/Constants'; describe(SmokeMultiChainAPI('wallet_notify'), () => { beforeEach(() => { diff --git a/e2e/specs/quarantine/deeplink-to-buy-flow-with-unsupported-network.failing.ts b/tests/smoke/ramps/deeplink-to-buy-flow-with-unsupported-network.failing.ts similarity index 64% rename from e2e/specs/quarantine/deeplink-to-buy-flow-with-unsupported-network.failing.ts rename to tests/smoke/ramps/deeplink-to-buy-flow-with-unsupported-network.failing.ts index e160d1dbb26..207fa606c21 100644 --- a/e2e/specs/quarantine/deeplink-to-buy-flow-with-unsupported-network.failing.ts +++ b/tests/smoke/ramps/deeplink-to-buy-flow-with-unsupported-network.failing.ts @@ -1,16 +1,16 @@ -import TestHelpers from '../../helpers'; -import { loginToApp } from '../../viewHelper'; -import { withFixtures } from '../../../tests/framework/fixtures/FixtureHelper'; -import { SmokeTrade } from '../../tags'; -import FixtureBuilder from '../../../tests/framework/fixtures/FixtureBuilder'; -import SellGetStartedView from '../../pages/Ramps/SellGetStartedView'; -import BuyGetStartedView from '../../pages/Ramps/BuyGetStartedView'; -import Assertions from '../../../tests/framework/Assertions'; -import NetworkAddedBottomSheet from '../../pages/Network/NetworkAddedBottomSheet'; -import NetworkApprovalBottomSheet from '../../pages/Network/NetworkApprovalBottomSheet'; -import NetworkEducationModal from '../../pages/Network/NetworkEducationModal'; -import NetworkListModal from '../../pages/Network/NetworkListModal'; -import { PopularNetworksList } from '../../../tests/resources/networks.e2e'; +import TestHelpers from '../../../e2e/helpers'; +import { loginToApp } from '../../../e2e/viewHelper'; +import { withFixtures } from '../../framework/fixtures/FixtureHelper'; +import { SmokeTrade } from '../../../e2e/tags'; +import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; +import SellGetStartedView from '../../../e2e/pages/Ramps/SellGetStartedView'; +import BuyGetStartedView from '../../../e2e/pages/Ramps/BuyGetStartedView'; +import Assertions from '../../framework/Assertions'; +import NetworkAddedBottomSheet from '../../../e2e/pages/Network/NetworkAddedBottomSheet'; +import NetworkApprovalBottomSheet from '../../../e2e/pages/Network/NetworkApprovalBottomSheet'; +import NetworkEducationModal from '../../../e2e/pages/Network/NetworkEducationModal'; +import NetworkListModal from '../../../e2e/pages/Network/NetworkListModal'; +import { PopularNetworksList } from '../../resources/networks.e2e'; // This test was migrated to the new framework but should be reworked to use withFixtures properly describe(SmokeTrade('Buy Crypto Deeplinks'), () => { diff --git a/e2e/specs/quarantine/deeplink-to-buy-flow.failing.ts b/tests/smoke/ramps/deeplink-to-buy-flow.failing.ts similarity index 74% rename from e2e/specs/quarantine/deeplink-to-buy-flow.failing.ts rename to tests/smoke/ramps/deeplink-to-buy-flow.failing.ts index 5adcaf095cf..8f1233ef521 100644 --- a/e2e/specs/quarantine/deeplink-to-buy-flow.failing.ts +++ b/tests/smoke/ramps/deeplink-to-buy-flow.failing.ts @@ -1,15 +1,15 @@ -import TestHelpers from '../../helpers'; -import { loginToApp } from '../../viewHelper'; -import { withFixtures } from '../../../tests/framework/fixtures/FixtureHelper'; -import { SmokeRamps } from '../../tags'; -import FixtureBuilder from '../../../tests/framework/fixtures/FixtureBuilder'; -import SellGetStartedView from '../../pages/Ramps/SellGetStartedView'; -import BuyGetStartedView from '../../pages/Ramps/BuyGetStartedView'; -import BuildQuoteView from '../../pages/Ramps/BuildQuoteView'; -import TokenSelectBottomSheet from '../../pages/Ramps/TokenSelectBottomSheet'; -import Assertions from '../../../tests/framework/Assertions'; -import { PopularNetworksList } from '../../../tests/resources/networks.e2e'; -import NetworkEducationModal from '../../pages/Network/NetworkEducationModal'; +import TestHelpers from '../../../e2e/helpers'; +import { loginToApp } from '../../../e2e/viewHelper'; +import { withFixtures } from '../../framework/fixtures/FixtureHelper'; +import { SmokeRamps } from '../../../e2e/tags'; +import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; +import SellGetStartedView from '../../../e2e/pages/Ramps/SellGetStartedView'; +import BuyGetStartedView from '../../../e2e/pages/Ramps/BuyGetStartedView'; +import BuildQuoteView from '../../../e2e/pages/Ramps/BuildQuoteView'; +import TokenSelectBottomSheet from '../../../e2e/pages/Ramps/TokenSelectBottomSheet'; +import Assertions from '../../framework/Assertions'; +import { PopularNetworksList } from '../../resources/networks.e2e'; +import NetworkEducationModal from '../../../e2e/pages/Network/NetworkEducationModal'; // This test was migrated to the new framework but should be reworked to use withFixtures properly describe(SmokeRamps('Buy Crypto Deeplinks'), () => { diff --git a/e2e/specs/quarantine/offramp-cashout.failing.ts b/tests/smoke/ramps/offramp-cashout.failing.ts similarity index 77% rename from e2e/specs/quarantine/offramp-cashout.failing.ts rename to tests/smoke/ramps/offramp-cashout.failing.ts index 83bdd781e0c..07ed56499c8 100644 --- a/e2e/specs/quarantine/offramp-cashout.failing.ts +++ b/tests/smoke/ramps/offramp-cashout.failing.ts @@ -1,25 +1,22 @@ -import { loginToApp } from '../../viewHelper'; -import FixtureBuilder from '../../../tests/framework/fixtures/FixtureBuilder'; -import { withFixtures } from '../../../tests/framework/fixtures/FixtureHelper'; -import { SmokeTrade } from '../../tags'; -import BuildQuoteView from '../../pages/Ramps/BuildQuoteView'; -import Assertions from '../../../tests/framework/Assertions'; -import WalletView from '../../pages/wallet/WalletView'; -import FundActionMenu from '../../pages/UI/FundActionMenu'; -import SelectPaymentMethodView from '../../pages/Ramps/SelectPaymentMethodView'; -import SellGetStartedView from '../../pages/Ramps/SellGetStartedView'; +import { loginToApp } from '../../../e2e/viewHelper'; +import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; +import { withFixtures } from '../../framework/fixtures/FixtureHelper'; +import { SmokeTrade } from '../../../e2e/tags'; +import BuildQuoteView from '../../../e2e/pages/Ramps/BuildQuoteView'; +import Assertions from '../../framework/Assertions'; +import WalletView from '../../../e2e/pages/wallet/WalletView'; +import FundActionMenu from '../../../e2e/pages/UI/FundActionMenu'; +import SelectPaymentMethodView from '../../../e2e/pages/Ramps/SelectPaymentMethodView'; +import SellGetStartedView from '../../../e2e/pages/Ramps/SellGetStartedView'; import { EventPayload, findEvent, getEventsPayloads, -} from '../../../tests/helpers/analytics/helpers'; -import SoftAssert from '../../../tests/framework/SoftAssert'; -import { - RampsRegions, - RampsRegionsEnum, -} from '../../../tests/framework/Constants'; +} from '../../helpers/analytics/helpers'; +import SoftAssert from '../../framework/SoftAssert'; +import { RampsRegions, RampsRegionsEnum } from '../../framework/Constants'; import { Mockttp } from 'mockttp'; -import { setupRegionAwareOnRampMocks } from '../../../tests/api-mocking/mock-responses/ramps/ramps-region-aware-mock-setup'; +import { setupRegionAwareOnRampMocks } from '../../api-mocking/mock-responses/ramps/ramps-region-aware-mock-setup'; const PaymentMethods = { SEPA_BANK_TRANSFER: 'SEPA Bank Transfer', diff --git a/e2e/specs/quarantine/offramp.failing.ts b/tests/smoke/ramps/offramp.failing.ts similarity index 91% rename from e2e/specs/quarantine/offramp.failing.ts rename to tests/smoke/ramps/offramp.failing.ts index e3986f918a5..04d45350413 100644 --- a/e2e/specs/quarantine/offramp.failing.ts +++ b/tests/smoke/ramps/offramp.failing.ts @@ -1,25 +1,22 @@ -import { loginToApp } from '../../viewHelper'; -import TabBarComponent from '../../pages/wallet/TabBarComponent'; -import WalletView from '../../pages/wallet/WalletView'; -import FundActionMenu from '../../pages/UI/FundActionMenu'; -import FixtureBuilder from '../../../tests/framework/fixtures/FixtureBuilder'; -import { withFixtures } from '../../../tests/framework/fixtures/FixtureHelper'; -import { CustomNetworks } from '../../../tests/resources/networks.e2e'; -import { SmokeTrade } from '../../tags'; -import Assertions from '../../../tests/framework/Assertions'; -import SellGetStartedView from '../../pages/Ramps/SellGetStartedView'; -import BuildQuoteView from '../../pages/Ramps/BuildQuoteView'; -import QuotesView from '../../pages/Ramps/QuotesView'; +import { loginToApp } from '../../../e2e/viewHelper'; +import TabBarComponent from '../../../e2e/pages/wallet/TabBarComponent'; +import WalletView from '../../../e2e/pages/wallet/WalletView'; +import FundActionMenu from '../../../e2e/pages/UI/FundActionMenu'; +import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; +import { withFixtures } from '../../framework/fixtures/FixtureHelper'; +import { CustomNetworks } from '../../resources/networks.e2e'; +import { SmokeTrade } from '../../../e2e/tags'; +import Assertions from '../../framework/Assertions'; +import SellGetStartedView from '../../../e2e/pages/Ramps/SellGetStartedView'; +import BuildQuoteView from '../../../e2e/pages/Ramps/BuildQuoteView'; +import QuotesView from '../../../e2e/pages/Ramps/QuotesView'; import { EventPayload, getEventsPayloads, -} from '../../../tests/helpers/analytics/helpers'; -import SoftAssert from '../../../tests/framework/SoftAssert'; -import { - RampsRegions, - RampsRegionsEnum, -} from '../../../tests/framework/Constants'; -import TestHelpers from '../../helpers'; +} from '../../helpers/analytics/helpers'; +import SoftAssert from '../../framework/SoftAssert'; +import { RampsRegions, RampsRegionsEnum } from '../../framework/Constants'; +import TestHelpers from '../../../e2e/helpers'; describe(SmokeTrade('Off-Ramp'), () => { let shouldCheckProviderSelectedEvents = true; diff --git a/e2e/specs/quarantine/onramp-limits.failing.ts b/tests/smoke/ramps/onramp-limits.failing.ts similarity index 63% rename from e2e/specs/quarantine/onramp-limits.failing.ts rename to tests/smoke/ramps/onramp-limits.failing.ts index 252fb34a855..ffad90bec66 100644 --- a/e2e/specs/quarantine/onramp-limits.failing.ts +++ b/tests/smoke/ramps/onramp-limits.failing.ts @@ -1,17 +1,14 @@ -import { loginToApp } from '../../viewHelper'; -import FixtureBuilder from '../../../tests/framework/fixtures/FixtureBuilder'; -import { withFixtures } from '../../../tests/framework/fixtures/FixtureHelper'; -import { SmokeTrade } from '../../tags'; -import BuildQuoteView from '../../pages/Ramps/BuildQuoteView'; -import Assertions from '../../../tests/framework/Assertions'; -import WalletView from '../../pages/wallet/WalletView'; -import FundActionMenu from '../../pages/UI/FundActionMenu'; -import BuyGetStartedView from '../../pages/Ramps/BuyGetStartedView'; -import { - RampsRegions, - RampsRegionsEnum, -} from '../../../tests/framework/Constants'; -import { setupRegionAwareOnRampMocks } from '../../../tests/api-mocking/mock-responses/ramps/ramps-region-aware-mock-setup'; +import { loginToApp } from '../../../e2e/viewHelper'; +import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; +import { withFixtures } from '../../framework/fixtures/FixtureHelper'; +import { SmokeTrade } from '../../../e2e/tags'; +import BuildQuoteView from '../../../e2e/pages/Ramps/BuildQuoteView'; +import Assertions from '../../framework/Assertions'; +import WalletView from '../../../e2e/pages/wallet/WalletView'; +import FundActionMenu from '../../../e2e/pages/UI/FundActionMenu'; +import BuyGetStartedView from '../../../e2e/pages/Ramps/BuyGetStartedView'; +import { RampsRegions, RampsRegionsEnum } from '../../framework/Constants'; +import { setupRegionAwareOnRampMocks } from '../../api-mocking/mock-responses/ramps/ramps-region-aware-mock-setup'; import { Mockttp } from 'mockttp'; /** diff --git a/e2e/specs/quarantine/onramp.failing.ts b/tests/smoke/ramps/onramp.failing.ts similarity index 92% rename from e2e/specs/quarantine/onramp.failing.ts rename to tests/smoke/ramps/onramp.failing.ts index 18e5599fa3f..d1ada2c1951 100644 --- a/e2e/specs/quarantine/onramp.failing.ts +++ b/tests/smoke/ramps/onramp.failing.ts @@ -1,25 +1,22 @@ -import { loginToApp } from '../../viewHelper'; -import WalletView from '../../pages/wallet/WalletView'; -import FundActionMenu from '../../pages/UI/FundActionMenu'; -import FixtureBuilder from '../../../tests/framework/fixtures/FixtureBuilder'; -import { withFixtures } from '../../../tests/framework/fixtures/FixtureHelper'; -import { CustomNetworks } from '../../../tests/resources/networks.e2e'; -import { SmokeTrade } from '../../tags'; -import Assertions from '../../../tests/framework/Assertions'; -import BuildQuoteView from '../../pages/Ramps/BuildQuoteView'; -import BuyGetStartedView from '../../pages/Ramps/BuyGetStartedView'; -import QuotesView from '../../pages/Ramps/QuotesView'; -import SoftAssert from '../../../tests/framework/SoftAssert'; +import { loginToApp } from '../../../e2e/viewHelper'; +import WalletView from '../../../e2e/pages/wallet/WalletView'; +import FundActionMenu from '../../../e2e/pages/UI/FundActionMenu'; +import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; +import { withFixtures } from '../../framework/fixtures/FixtureHelper'; +import { CustomNetworks } from '../../resources/networks.e2e'; +import { SmokeTrade } from '../../../e2e/tags'; +import Assertions from '../../framework/Assertions'; +import BuildQuoteView from '../../../e2e/pages/Ramps/BuildQuoteView'; +import BuyGetStartedView from '../../../e2e/pages/Ramps/BuyGetStartedView'; +import QuotesView from '../../../e2e/pages/Ramps/QuotesView'; +import SoftAssert from '../../framework/SoftAssert'; import { EventPayload, getEventsPayloads, -} from '../../../tests/helpers/analytics/helpers'; -import { - RampsRegions, - RampsRegionsEnum, -} from '../../../tests/framework/Constants'; +} from '../../helpers/analytics/helpers'; +import { RampsRegions, RampsRegionsEnum } from '../../framework/Constants'; import { Mockttp } from 'mockttp'; -import { setupRegionAwareOnRampMocks } from '../../../tests/api-mocking/mock-responses/ramps/ramps-region-aware-mock-setup'; +import { setupRegionAwareOnRampMocks } from '../../api-mocking/mock-responses/ramps/ramps-region-aware-mock-setup'; const eventsToCheck: EventPayload[] = []; diff --git a/e2e/specs/quarantine/browser/browser-tests.failing.ts b/tests/smoke/wallet/browser/browser-tests.failing.ts similarity index 85% rename from e2e/specs/quarantine/browser/browser-tests.failing.ts rename to tests/smoke/wallet/browser/browser-tests.failing.ts index 8431967ee59..e1e22515aea 100644 --- a/e2e/specs/quarantine/browser/browser-tests.failing.ts +++ b/tests/smoke/wallet/browser/browser-tests.failing.ts @@ -1,21 +1,21 @@ -import { SmokeWalletPlatform } from '../../../tags.js'; -import { loginToApp, navigateToBrowserView } from '../../../viewHelper.ts'; -import FixtureBuilder from '../../../../tests/framework/fixtures/FixtureBuilder.ts'; -import { withFixtures } from '../../../../tests/framework/fixtures/FixtureHelper.ts'; -import ExternalSites from '../../../../tests/resources/externalsites.json'; -import Browser from '../../../pages/Browser/BrowserView.ts'; -import EnsWebsite from '../../../pages/Browser/ExternalWebsites/EnsWebsite.ts'; -import Assertions from '../../../../tests/framework/Assertions.ts'; -import ConnectBottomSheet from '../../../pages/Browser/ConnectBottomSheet.ts'; -import RedirectWebsite from '../../../pages/Browser/ExternalWebsites/RedirectWebsite.ts'; -import UniswapWebsite from '../../../pages/Browser/ExternalWebsites/UniswapWebsite.ts'; -import OpenseaWebsite from '../../../pages/Browser/ExternalWebsites/OpenseaWebsite.ts'; -import PancakeSwapWebsite from '../../../pages/Browser/ExternalWebsites/PancakeSwapWebsite.ts'; -import DownloadFile from '../../../pages/Browser/DownloadFile.ts'; -import DownloadFileWebsite from '../../../pages/Browser/ExternalWebsites/DownloadFileWebsite.ts'; -import TestHelpers from '../../../helpers.js'; -import CameraWebsite from '../../../pages/Browser/ExternalWebsites/Security/CameraWebsite.ts'; -import HistoryDisclosureWebsite from '../../../pages/Browser/ExternalWebsites/Security/HistoryDisclosureWebsite.ts'; +import { SmokeWalletPlatform } from '../../../../e2e/tags.js'; +import { loginToApp, navigateToBrowserView } from '../../../../e2e/viewHelper'; +import FixtureBuilder from '../../../framework/fixtures/FixtureBuilder'; +import { withFixtures } from '../../../framework/fixtures/FixtureHelper'; +import ExternalSites from '../../../resources/externalsites.json'; +import Browser from '../../../../e2e/pages/Browser/BrowserView'; +import EnsWebsite from '../../../../e2e/pages/Browser/ExternalWebsites/EnsWebsite'; +import Assertions from '../../../framework/Assertions'; +import ConnectBottomSheet from '../../../../e2e/pages/Browser/ConnectBottomSheet'; +import RedirectWebsite from '../../../../e2e/pages/Browser/ExternalWebsites/RedirectWebsite'; +import UniswapWebsite from '../../../../e2e/pages/Browser/ExternalWebsites/UniswapWebsite'; +import OpenseaWebsite from '../../../../e2e/pages/Browser/ExternalWebsites/OpenseaWebsite'; +import PancakeSwapWebsite from '../../../../e2e/pages/Browser/ExternalWebsites/PancakeSwapWebsite'; +import DownloadFile from '../../../../e2e/pages/Browser/DownloadFile'; +import DownloadFileWebsite from '../../../../e2e/pages/Browser/ExternalWebsites/DownloadFileWebsite'; +import TestHelpers from '../../../../e2e/helpers.js'; +import CameraWebsite from '../../../../e2e/pages/Browser/ExternalWebsites/Security/CameraWebsite'; +import HistoryDisclosureWebsite from '../../../../e2e/pages/Browser/ExternalWebsites/Security/HistoryDisclosureWebsite'; const getOriginFromURL = (url: string): string => { try { diff --git a/tests/tools/e2e-ai-analyzer/modes/select-tags/prompt.ts b/tests/tools/e2e-ai-analyzer/modes/select-tags/prompt.ts index 1ec9f1961b3..ec180bfa848 100644 --- a/tests/tools/e2e-ai-analyzer/modes/select-tags/prompt.ts +++ b/tests/tools/e2e-ai-analyzer/modes/select-tags/prompt.ts @@ -42,7 +42,7 @@ Critical files (marked in file list) typically warrant wide testing. Use tools t For E2E test infrastructure related changes, consider running the necessary tests or all of them in case the changes are wide-ranging. Balance thoroughness with efficiency, and be conservative in your risk assessment. When in doubt, err on the side of running more test tags to ensure adequate coverage. Do not exceed the maximum number of analysis iterations which is ${LLM_CONFIG.maxIterations}, i.e. try to decide before the maximum number of iterations is reached. -FlaskBuildTests is for MetaMask Snaps functionality. Select this tag when changes affect e2e/specs/snaps/ directory, snap-related app code (snap permissions, snap state, snap UI, browser), or Flask build configuration.`; +FlaskBuildTests is for MetaMask Snaps functionality. Select this tag when changes affect tests/smoke/snaps/ directory, snap-related app code (snap permissions, snap state, snap UI, browser), or Flask build configuration.`; const performanceGuidanceSection = `PERFORMANCE TEST GUIDANCE: Performance tests measure app responsiveness and render times. Select performance tests when changes could impact: diff --git a/yarn.lock b/yarn.lock index 448dd5e3a03..c666346e0a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3546,6 +3546,16 @@ __metadata: languageName: node linkType: hard +"@expensify/react-native-wallet@npm:^0.1.15": + version: 0.1.15 + resolution: "@expensify/react-native-wallet@npm:0.1.15" + peerDependencies: + react: "*" + react-native: "*" + checksum: 10/7708a6f73112aec7ec19a9b3fc816dd757a063fe2cdf82d1c3c7be4d5ba2db72d7537596868650a82201e06a24e748fff1e07d09aa381a78dd437a49d394ac28 + languageName: node + linkType: hard + "@expo/apple-utils@npm:2.0.3": version: 2.0.3 resolution: "@expo/apple-utils@npm:2.0.3" @@ -7371,15 +7381,15 @@ __metadata: languageName: node linkType: hard -"@metamask/address-book-controller@npm:^7.0.0": - version: 7.0.0 - resolution: "@metamask/address-book-controller@npm:7.0.0" +"@metamask/address-book-controller@npm:^7.0.1": + version: 7.0.1 + resolution: "@metamask/address-book-controller@npm:7.0.1" dependencies: "@metamask/base-controller": "npm:^9.0.0" - "@metamask/controller-utils": "npm:^11.14.1" + "@metamask/controller-utils": "npm:^11.16.0" "@metamask/messenger": "npm:^0.3.0" "@metamask/utils": "npm:^11.8.1" - checksum: 10/fdd229af695d0b2d9b7683e54a4f5f7121e9fcdcfd5cd7cf60f010726bc7627f5bd67b61665152c75e79d491712150e2c4383d4da39460535bf8eb5da49763bf + checksum: 10/5ada2568b7093e33b93f9caad64e3e72dd7b581ee2758cee5ea8b261b2efedbfd355659f2d2917080399e4ca782bfaa0c12be5383f2db017302d98d2902a480d languageName: node linkType: hard @@ -7479,9 +7489,9 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controllers@npm:^99.0.0, @metamask/assets-controllers@npm:^99.1.0": - version: 99.1.0 - resolution: "@metamask/assets-controllers@npm:99.1.0" +"@metamask/assets-controllers@npm:^99.0.0, @metamask/assets-controllers@npm:^99.2.0": + version: 99.2.0 + resolution: "@metamask/assets-controllers@npm:99.2.0" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" @@ -7508,14 +7518,14 @@ __metadata: "@metamask/permission-controller": "npm:^12.2.0" "@metamask/phishing-controller": "npm:^16.1.0" "@metamask/polling-controller": "npm:^16.0.2" - "@metamask/preferences-controller": "npm:^22.0.0" + "@metamask/preferences-controller": "npm:^22.1.0" "@metamask/profile-sync-controller": "npm:^27.0.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/snaps-controllers": "npm:^17.2.0" "@metamask/snaps-sdk": "npm:^10.3.0" "@metamask/snaps-utils": "npm:^11.7.0" - "@metamask/storage-service": "npm:^0.0.1" - "@metamask/transaction-controller": "npm:^62.11.0" + "@metamask/storage-service": "npm:^1.0.0" + "@metamask/transaction-controller": "npm:^62.13.0" "@metamask/utils": "npm:^11.9.0" "@types/bn.js": "npm:^5.1.5" "@types/uuid": "npm:^8.3.0" @@ -7531,7 +7541,7 @@ __metadata: peerDependencies: "@metamask/providers": ^22.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/01e9e33f50d5207817264c969ee37ad534399756d580ff1ebb965018cba0c669a181ba70273ba6901e5c71c2f5acd7506b2ff12432c9b218cfa778e3d12c59b7 + checksum: 10/948f4f15aeee304d370eb8ac2c29048de4075329e8f8285e5c1677c13bd6be3729f762e91623fed1c4687d5d6b75eac93519b5cefd1fc2d62525ee965b06f6d0 languageName: node linkType: hard @@ -7597,10 +7607,10 @@ __metadata: languageName: node linkType: hard -"@metamask/bitcoin-wallet-snap@npm:^1.9.0": - version: 1.9.0 - resolution: "@metamask/bitcoin-wallet-snap@npm:1.9.0" - checksum: 10/cfcd2da23ccccddeb981d2e81576995e6ee9ad4e16023d238219c29feecf9e5ac9a49baeadc79013d2001b6aa20170897f082ae9c18c91ae124e17688aa4f973 +"@metamask/bitcoin-wallet-snap@npm:^1.10.0": + version: 1.10.0 + resolution: "@metamask/bitcoin-wallet-snap@npm:1.10.0" + checksum: 10/43bc519f0d6f243658c32ddce3418277f2c7b745a37713ad2eb7927ff6fcc038f7c23dc6fb1933b56bc7ffb5e6dacf403470ee215cf5e0a138e765cd0b14ada5 languageName: node linkType: hard @@ -7635,9 +7645,9 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-controller@npm:^65.1.0": - version: 65.1.0 - resolution: "@metamask/bridge-controller@npm:65.1.0" +"@metamask/bridge-controller@npm:^65.1.0, @metamask/bridge-controller@npm:^65.2.0": + version: 65.3.0 + resolution: "@metamask/bridge-controller@npm:65.3.0" dependencies: "@ethersproject/address": "npm:^5.7.0" "@ethersproject/bignumber": "npm:^5.7.0" @@ -7645,7 +7655,7 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/accounts-controller": "npm:^35.0.2" - "@metamask/assets-controllers": "npm:^99.0.0" + "@metamask/assets-controllers": "npm:^99.2.0" "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.18.0" "@metamask/gas-fee-controller": "npm:^26.0.2" @@ -7657,12 +7667,12 @@ __metadata: "@metamask/polling-controller": "npm:^16.0.2" "@metamask/remote-feature-flag-controller": "npm:^4.0.0" "@metamask/snaps-controllers": "npm:^17.2.0" - "@metamask/transaction-controller": "npm:^62.11.0" + "@metamask/transaction-controller": "npm:^62.14.0" "@metamask/utils": "npm:^11.9.0" bignumber.js: "npm:^9.1.2" reselect: "npm:^5.1.1" uuid: "npm:^8.3.2" - checksum: 10/778c219ecd44f808f936ebb6f5ffe1dd8689e3b160522482d861f7be37d7fed6488561f4d5d4adbcdf63a57f1cedb0f50ff65e06098af5ecf20abfb2ecbbcabb + checksum: 10/204030f6754dc62f220db7c90ca9e248ab00da20759c9eb895f9b0c00f342895a918434bf10650c5c156cdb92bce3ff2263bd1b08ddd60691e0f6cd016c3a770 languageName: node linkType: hard @@ -7789,19 +7799,19 @@ __metadata: languageName: node linkType: hard -"@metamask/core-backend@npm:^5.0.0": - version: 5.0.0 - resolution: "@metamask/core-backend@npm:5.0.0" +"@metamask/core-backend@npm:^5.0.0, @metamask/core-backend@npm:^5.1.0": + version: 5.1.0 + resolution: "@metamask/core-backend@npm:5.1.0" dependencies: - "@metamask/controller-utils": "npm:^11.16.0" + "@metamask/accounts-controller": "npm:^35.0.2" + "@metamask/controller-utils": "npm:^11.18.0" + "@metamask/keyring-controller": "npm:^25.1.0" "@metamask/messenger": "npm:^0.3.0" "@metamask/profile-sync-controller": "npm:^27.0.0" - "@metamask/utils": "npm:^11.8.1" + "@metamask/utils": "npm:^11.9.0" + "@tanstack/query-core": "npm:^5.62.16" uuid: "npm:^8.3.2" - peerDependencies: - "@metamask/accounts-controller": ^35.0.0 - "@metamask/keyring-controller": ^25.0.0 - checksum: 10/c3c8d527ccbc9d56f6ddb5579cc8c58af971e9b81ece48ea7107c48e496ec2574283119cd4b258cc6c733f15d1432632a4e975d7616809147e2d4510dba59219 + checksum: 10/8d966656b33a5582772ab6c29d04d7d7f9d47ffb52ad18a2d06f9e134a9bc6939b835452224230fc269631ee394b6642473dec8b309fead1e1f324b6e26f286e languageName: node linkType: hard @@ -8966,16 +8976,15 @@ __metadata: languageName: node linkType: hard -"@metamask/preferences-controller@npm:^22.0.0": - version: 22.0.0 - resolution: "@metamask/preferences-controller@npm:22.0.0" +"@metamask/preferences-controller@npm:^22.0.0, @metamask/preferences-controller@npm:^22.1.0": + version: 22.1.0 + resolution: "@metamask/preferences-controller@npm:22.1.0" dependencies: "@metamask/base-controller": "npm:^9.0.0" - "@metamask/controller-utils": "npm:^11.16.0" + "@metamask/controller-utils": "npm:^11.18.0" + "@metamask/keyring-controller": "npm:^25.1.0" "@metamask/messenger": "npm:^0.3.0" - peerDependencies: - "@metamask/keyring-controller": ^25.0.0 - checksum: 10/c0b11266efaacbf612652ccacbe013bff03333004107a95422491755641075b52c8ff073a8abea802faeb922be4147cb7ce8feac3305b1ce2e4e070195b4b414 + checksum: 10/bdb6a727cd88313b29b66dfd10308accc8c8bf52830bd7950a5afa7c832554958b94d207ed46ba4b7c8645cd05697e4601ee18194f988416d8dec0bbd2977516 languageName: node linkType: hard @@ -9005,27 +9014,27 @@ __metadata: languageName: node linkType: hard -"@metamask/profile-sync-controller@npm:^27.0.0": - version: 27.0.0 - resolution: "@metamask/profile-sync-controller@npm:27.0.0" +"@metamask/profile-sync-controller@npm:^27.0.0, @metamask/profile-sync-controller@npm:^27.1.0": + version: 27.1.0 + resolution: "@metamask/profile-sync-controller@npm:27.1.0" dependencies: + "@metamask/address-book-controller": "npm:^7.0.1" "@metamask/base-controller": "npm:^9.0.0" + "@metamask/keyring-controller": "npm:^25.1.0" "@metamask/messenger": "npm:^0.3.0" - "@metamask/snaps-sdk": "npm:^9.0.0" - "@metamask/snaps-utils": "npm:^11.0.0" - "@metamask/utils": "npm:^11.8.1" + "@metamask/snaps-controllers": "npm:^17.2.0" + "@metamask/snaps-sdk": "npm:^10.3.0" + "@metamask/snaps-utils": "npm:^11.7.0" + "@metamask/utils": "npm:^11.9.0" "@noble/ciphers": "npm:^1.3.0" "@noble/hashes": "npm:^1.8.0" immer: "npm:^9.0.6" loglevel: "npm:^1.8.1" siwe: "npm:^2.3.2" peerDependencies: - "@metamask/address-book-controller": ^7.0.1 - "@metamask/keyring-controller": ^25.0.0 "@metamask/providers": ^22.0.0 - "@metamask/snaps-controllers": ^14.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/3bfa8f483a1dc6c0cc5301d3d02570afadea698c71910c0b53d9a840d172bcaeeaca0d8f2f93c57f4c00b7fd65ef38a62357ec45caf05adc43128c748254f6fa + checksum: 10/8f07889ab3ca5d235fd6331e412fa051430d8762853f696da71386bb2509058a53ba5ab7bc227c348a16f2855440792daa43147cea0ef9d83df345968a0ed2f5 languageName: node linkType: hard @@ -9071,14 +9080,14 @@ __metadata: languageName: node linkType: hard -"@metamask/ramps-controller@npm:^6.0.0": - version: 6.0.0 - resolution: "@metamask/ramps-controller@npm:6.0.0" +"@metamask/ramps-controller@npm:^7.0.0": + version: 7.0.0 + resolution: "@metamask/ramps-controller@npm:7.0.0" dependencies: "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.18.0" "@metamask/messenger": "npm:^0.3.0" - checksum: 10/a54560eb1cab00632ebcfe202dab7173827abaa14350874bfd8f09f66b450e48cae911f323fddd76c7af0b660032f517d02e8b9d94323b90b4cc2d4340f714a7 + checksum: 10/12f16f32c7d12686c9af23c7c3a6d120f91f415993c91ba58f1ca656a000908da787d1a02fb2238ce166b0ee5fb450f8d35d5a8d9ee7e1b685dacb4adc902a24 languageName: node linkType: hard @@ -9542,16 +9551,6 @@ __metadata: languageName: node linkType: hard -"@metamask/storage-service@npm:^0.0.1": - version: 0.0.1 - resolution: "@metamask/storage-service@npm:0.0.1" - dependencies: - "@metamask/messenger": "npm:^0.3.0" - "@metamask/utils": "npm:^11.8.1" - checksum: 10/ba2443e4bab7a4ef64bf3e0a10403ef94d50225e5ae5931a6fd32a21bfa8e276916ab6dc377d2db30c15c50acaeaee74a3c0b418a563311da9f98b9172fba300 - languageName: node - linkType: hard - "@metamask/storage-service@npm:^1.0.0": version: 1.0.0 resolution: "@metamask/storage-service@npm:1.0.0" @@ -9689,6 +9688,45 @@ __metadata: languageName: node linkType: hard +"@metamask/transaction-controller@npm:^62.11.0, @metamask/transaction-controller@npm:^62.13.0": + version: 62.14.0 + resolution: "@metamask/transaction-controller@npm:62.14.0" + dependencies: + "@ethereumjs/common": "npm:^4.4.0" + "@ethereumjs/tx": "npm:^5.4.0" + "@ethereumjs/util": "npm:^9.1.0" + "@ethersproject/abi": "npm:^5.7.0" + "@ethersproject/contracts": "npm:^5.7.0" + "@ethersproject/providers": "npm:^5.7.0" + "@ethersproject/wallet": "npm:^5.7.0" + "@metamask/accounts-controller": "npm:^35.0.2" + "@metamask/approval-controller": "npm:^8.0.0" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/controller-utils": "npm:^11.18.0" + "@metamask/core-backend": "npm:^5.1.0" + "@metamask/eth-query": "npm:^4.0.0" + "@metamask/gas-fee-controller": "npm:^26.0.2" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/metamask-eth-abis": "npm:^3.1.1" + "@metamask/network-controller": "npm:^29.0.0" + "@metamask/nonce-tracker": "npm:^6.0.0" + "@metamask/remote-feature-flag-controller": "npm:^4.0.0" + "@metamask/rpc-errors": "npm:^7.0.2" + "@metamask/utils": "npm:^11.9.0" + async-mutex: "npm:^0.5.0" + bignumber.js: "npm:^9.1.2" + bn.js: "npm:^5.2.1" + eth-method-registry: "npm:^4.0.0" + fast-json-patch: "npm:^3.1.1" + lodash: "npm:^4.17.21" + uuid: "npm:^8.3.2" + peerDependencies: + "@babel/runtime": ^7.0.0 + "@metamask/eth-block-tracker": ">=9" + checksum: 10/8ddc99d447990b997becc67617c8a5f4345d6412a6da53314f369bf0f12eff8be142a907e712f82aed6fe428ce08fa386797103d3d55a745b64f85ff5b19dbb0 + languageName: node + linkType: hard + "@metamask/transaction-controller@patch:@metamask/transaction-controller@npm%3A62.10.0#~/.yarn/patches/@metamask-transaction-controller-npm-62.10.0-e1d24e7db5.patch": version: 62.10.0 resolution: "@metamask/transaction-controller@patch:@metamask/transaction-controller@npm%3A62.10.0#~/.yarn/patches/@metamask-transaction-controller-npm-62.10.0-e1d24e7db5.patch::version=62.10.0&hash=dae606" @@ -9728,15 +9766,15 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-pay-controller@npm:^12.0.2": - version: 12.0.2 - resolution: "@metamask/transaction-pay-controller@npm:12.0.2" +"@metamask/transaction-pay-controller@npm:^12.1.0": + version: 12.1.0 + resolution: "@metamask/transaction-pay-controller@npm:12.1.0" dependencies: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" - "@metamask/assets-controllers": "npm:^99.1.0" + "@metamask/assets-controllers": "npm:^99.2.0" "@metamask/base-controller": "npm:^9.0.0" - "@metamask/bridge-controller": "npm:^65.1.0" + "@metamask/bridge-controller": "npm:^65.2.0" "@metamask/bridge-status-controller": "npm:^65.0.1" "@metamask/controller-utils": "npm:^11.18.0" "@metamask/gas-fee-controller": "npm:^26.0.2" @@ -9744,13 +9782,13 @@ __metadata: "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/network-controller": "npm:^29.0.0" "@metamask/remote-feature-flag-controller": "npm:^4.0.0" - "@metamask/transaction-controller": "npm:^62.11.0" + "@metamask/transaction-controller": "npm:^62.14.0" "@metamask/utils": "npm:^11.9.0" bignumber.js: "npm:^9.1.2" bn.js: "npm:^5.2.1" immer: "npm:^9.0.6" lodash: "npm:^4.17.21" - checksum: 10/e22613a2dce4670bd00d09e698666bf633737bef99125ebcc186dceb151c629c3386ef6f3373c436e9970b09fc1c52bd1c9ffc4e26140bb6a8a3f7cf75ca4299 + checksum: 10/4464536eb852afa32a74bef18f41fc6d6c605b51c5bebf95f1e6a7d2cb71a5d1f5c8a6b0034c56154e8724e36b42e8a862216609a9fc3869bd6abbd3e39c67f6 languageName: node linkType: hard @@ -16949,6 +16987,13 @@ __metadata: languageName: node linkType: hard +"@tanstack/query-core@npm:^5.62.16": + version: 5.90.20 + resolution: "@tanstack/query-core@npm:5.90.20" + checksum: 10/25e38f4382442bc15e0f6cce8d787e9df8d8822c61d3f3e9427e89e01b1e2506f848292e086dae29aeb55f8ce71b097c34221f3c5eda37fb4a688b5ceca5d1b3 + languageName: node + linkType: hard + "@testim/chrome-version@npm:^1.1.4": version: 1.1.4 resolution: "@testim/chrome-version@npm:1.1.4" @@ -34647,6 +34692,7 @@ __metadata: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.2" + "@expensify/react-native-wallet": "npm:^0.1.15" "@expo/fingerprint": "npm:^0.15.0" "@expo/repack-app": "npm:^0.2.9" "@google/generative-ai": "npm:^0.24.1" @@ -34664,14 +34710,14 @@ __metadata: "@metamask/account-api": "npm:^0.12.0" "@metamask/account-tree-controller": "npm:^3.0.0" "@metamask/accounts-controller": "npm:^34.0.0" - "@metamask/address-book-controller": "npm:^7.0.0" + "@metamask/address-book-controller": "npm:^7.0.1" "@metamask/analytics-controller": "npm:^1.0.0" "@metamask/app-metadata-controller": "npm:^2.0.0" "@metamask/approval-controller": "npm:^8.0.0" "@metamask/assets-controllers": "npm:^99.0.0" "@metamask/auto-changelog": "npm:^5.3.0" "@metamask/base-controller": "npm:^9.0.0" - "@metamask/bitcoin-wallet-snap": "npm:^1.9.0" + "@metamask/bitcoin-wallet-snap": "npm:^1.10.0" "@metamask/bridge-controller": "npm:^64.8.0" "@metamask/bridge-status-controller": "npm:^64.4.5" "@metamask/browser-passworder": "npm:^5.0.0" @@ -34737,9 +34783,9 @@ __metadata: "@metamask/preferences-controller": "npm:^21.0.0" "@metamask/preinstalled-example-snap": "npm:^0.7.2" "@metamask/profile-metrics-controller": "npm:^2.0.0" - "@metamask/profile-sync-controller": "npm:^27.0.0" + "@metamask/profile-sync-controller": "npm:^27.1.0" "@metamask/providers": "npm:^18.3.1" - "@metamask/ramps-controller": "npm:^6.0.0" + "@metamask/ramps-controller": "npm:^7.0.0" "@metamask/react-native-acm": "npm:^1.0.1" "@metamask/react-native-actionsheet": "npm:2.4.2" "@metamask/react-native-button": "npm:^3.0.0" @@ -34771,8 +34817,8 @@ __metadata: "@metamask/test-dapp": "npm:9.5.0" "@metamask/test-dapp-multichain": "npm:^0.17.1" "@metamask/test-dapp-solana": "npm:^0.3.0" - "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.10.0#~/.yarn/patches/@metamask-transaction-controller-npm-62.10.0-e1d24e7db5.patch" - "@metamask/transaction-pay-controller": "npm:^12.0.2" + "@metamask/transaction-controller": "npm:^62.14.0" + "@metamask/transaction-pay-controller": "npm:^12.1.0" "@metamask/tron-wallet-snap": "npm:^1.19.2" "@metamask/utils": "npm:^11.8.1" "@ngraveio/bc-ur": "npm:^1.1.6"