diff --git a/.github/workflows/run-e2e-regression-tests-android.yml b/.github/workflows/run-e2e-regression-tests-android.yml index b9b7562fb8a..c45ede5dfcd 100644 --- a/.github/workflows/run-e2e-regression-tests-android.yml +++ b/.github/workflows/run-e2e-regression-tests-android.yml @@ -45,22 +45,6 @@ jobs: total_splits: 4 secrets: inherit - regression-confirmations-redesigned-android: - name: 'Confirmations Redesigned Regression (Android) - ${{ matrix.split }}' - needs: [build-android-apks] - strategy: - matrix: - split: [1, 2] - fail-fast: false - uses: ./.github/workflows/run-e2e-workflow.yml - with: - test-suite-name: regression-confirmations-redesigned-android-${{ matrix.split }} - platform: android - test_suite_tag: 'RegressionConfirmationsRedesigned' - split_number: ${{ matrix.split }} - total_splits: 2 - secrets: inherit - regression-trade-android: name: 'Trade Regression (Android) - ${{ matrix.split }}' needs: [build-android-apks] @@ -180,7 +164,6 @@ jobs: needs: - build-android-apks - regression-confirmations-android - - regression-confirmations-redesigned-android - regression-trade-android - regression-wallet-platform-android - regression-accounts-android diff --git a/.github/workflows/run-e2e-smoke-tests-android.yml b/.github/workflows/run-e2e-smoke-tests-android.yml index 1330e161a22..0c81f938709 100644 --- a/.github/workflows/run-e2e-smoke-tests-android.yml +++ b/.github/workflows/run-e2e-smoke-tests-android.yml @@ -131,19 +131,19 @@ jobs: changed_files: ${{ inputs.changed_files }} secrets: inherit - confirmations-redesigned-android-smoke: - if: contains(fromJson(inputs.selected_tags), 'ALL') || contains(fromJson(inputs.selected_tags), 'SmokeConfirmationsRedesigned') + confirmations-android-smoke: + if: contains(fromJson(inputs.selected_tags), 'ALL') || contains(fromJson(inputs.selected_tags), 'SmokeConfirmations') strategy: matrix: - split: [1, 2, 3] + split: [1, 2, 3, 4] fail-fast: false uses: ./.github/workflows/run-e2e-workflow.yml with: - test-suite-name: confirmations-redesigned-android-smoke-${{ matrix.split }} + test-suite-name: confirmations-android-smoke-${{ matrix.split }} platform: android - test_suite_tag: 'SmokeConfirmationsRedesigned' + test_suite_tag: 'SmokeConfirmations' split_number: ${{ matrix.split }} - total_splits: 3 + total_splits: 4 changed_files: ${{ inputs.changed_files }} secrets: inherit @@ -163,22 +163,6 @@ jobs: changed_files: ${{ inputs.changed_files }} secrets: inherit - rewards-android-smoke: - if: contains(fromJson(inputs.selected_tags), 'ALL') || contains(fromJson(inputs.selected_tags), 'SmokeRewards') - strategy: - matrix: - split: [1] - fail-fast: false - uses: ./.github/workflows/run-e2e-workflow.yml - with: - test-suite-name: rewards-android-smoke-${{ matrix.split }} - platform: android - test_suite_tag: 'SmokeRewards' - split_number: ${{ matrix.split }} - total_splits: 1 - changed_files: ${{ inputs.changed_files }} - secrets: inherit - card-android-smoke: if: contains(fromJson(inputs.selected_tags), 'ALL') || contains(fromJson(inputs.selected_tags), 'SmokeCard') strategy: @@ -239,9 +223,8 @@ jobs: - accounts-android-smoke - network-abstraction-android-smoke - network-expansion-android-smoke - - confirmations-redesigned-android-smoke + - confirmations-android-smoke - prediction-market-android-smoke - - rewards-android-smoke - card-android-smoke - ramps-android-smoke - multichain-api-android-smoke diff --git a/.github/workflows/run-e2e-smoke-tests-ios.yml b/.github/workflows/run-e2e-smoke-tests-ios.yml index be4ca35af0f..4bff0ac6607 100644 --- a/.github/workflows/run-e2e-smoke-tests-ios.yml +++ b/.github/workflows/run-e2e-smoke-tests-ios.yml @@ -19,19 +19,19 @@ permissions: id-token: write jobs: - confirmations-redesigned-ios-smoke: - if: contains(fromJson(inputs.selected_tags), 'ALL') || contains(fromJson(inputs.selected_tags), 'SmokeConfirmationsRedesigned') + confirmations-ios-smoke: + if: contains(fromJson(inputs.selected_tags), 'ALL') || contains(fromJson(inputs.selected_tags), 'SmokeConfirmations') strategy: matrix: - split: [1, 2, 3] + split: [1, 2, 3, 4] fail-fast: false uses: ./.github/workflows/run-e2e-workflow.yml with: - test-suite-name: confirmations-redesigned-ios-smoke-${{ matrix.split }} + test-suite-name: confirmations-ios-smoke-${{ matrix.split }} platform: ios - test_suite_tag: 'SmokeConfirmationsRedesigned' + test_suite_tag: 'SmokeConfirmations' split_number: ${{ matrix.split }} - total_splits: 3 + total_splits: 4 changed_files: ${{ inputs.changed_files }} secrets: inherit @@ -163,22 +163,6 @@ jobs: changed_files: ${{ inputs.changed_files }} secrets: inherit - rewards-ios-smoke: - if: contains(fromJson(inputs.selected_tags), 'ALL') || contains(fromJson(inputs.selected_tags), 'SmokeRewards') - strategy: - matrix: - split: [1] - fail-fast: false - uses: ./.github/workflows/run-e2e-workflow.yml - with: - test-suite-name: rewards-ios-smoke-${{ matrix.split }} - platform: ios - test_suite_tag: 'SmokeRewards' - split_number: ${{ matrix.split }} - total_splits: 1 - changed_files: ${{ inputs.changed_files }} - secrets: inherit - card-ios-smoke: if: contains(fromJson(inputs.selected_tags), 'ALL') || contains(fromJson(inputs.selected_tags), 'SmokeCard') strategy: @@ -232,7 +216,7 @@ jobs: runs-on: ubuntu-latest if: ${{ !cancelled() && inputs.selected_tags != '[]' && inputs.selected_tags != '["FlaskBuildTests"]' }} needs: - - confirmations-redesigned-ios-smoke + - confirmations-ios-smoke - trade-ios-smoke - perps-ios-smoke - wallet-platform-ios-smoke @@ -241,7 +225,6 @@ jobs: - network-abstraction-ios-smoke - network-expansion-ios-smoke - prediction-market-ios-smoke - - rewards-ios-smoke - card-ios-smoke - ramps-ios-smoke - multichain-api-ios-smoke diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/MultichainAccountSelectorList.styles.ts b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/MultichainAccountSelectorList.styles.ts index ebd99918276..0ebdc76d70e 100644 --- a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/MultichainAccountSelectorList.styles.ts +++ b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/MultichainAccountSelectorList.styles.ts @@ -33,7 +33,7 @@ const createStyles = (params: { searchTextField: { backgroundColor: theme.colors.background.muted, borderWidth: 0, - borderRadius: 16, + borderRadius: 9999, padding: 0, }, listContainer: { diff --git a/app/components/Nav/App/App.tsx b/app/components/Nav/App/App.tsx index a59c06ae4d6..9dfabc307c5 100644 --- a/app/components/Nav/App/App.tsx +++ b/app/components/Nav/App/App.tsx @@ -151,7 +151,6 @@ import { State2AccountConnectWrapper } from '../../Views/MultichainAccounts/Mult import { SmartAccountModal } from '../../Views/MultichainAccounts/AccountDetails/components/SmartAccountModal/SmartAccountModal'; import TradeWalletActions from '../../Views/TradeWalletActions'; import { BIP44AccountPermissionWrapper } from '../../Views/MultichainAccounts/MultichainPermissionsSummary/BIP44AccountPermissionWrapper'; -import { useEmptyNavHeaderForConfirmations } from '../../Views/confirmations/hooks/ui/useEmptyNavHeaderForConfirmations'; import SocialLoginIosUser from '../../Views/SocialLoginIosUser'; import { useOTAUpdates } from '../../hooks/useOTAUpdates'; import MultichainTransactionDetailsSheet from '../../UI/MultichainTransactionDetailsModal/MultichainTransactionDetailsSheet'; @@ -887,7 +886,6 @@ const ModalSmartAccountOptIn = () => ( const AppFlow = () => { const userLoggedIn = useSelector(selectUserLoggedIn); - const emptyNavHeaderOptions = useEmptyNavHeaderForConfirmations(); return ( <> @@ -1071,7 +1069,7 @@ const AppFlow = () => { /> { - {renderDeprecatedNetworkAlert( diff --git a/app/components/UI/AssetSearch/index.test.tsx b/app/components/UI/AssetSearch/index.test.tsx index bd92c038fe2..8578f060edc 100644 --- a/app/components/UI/AssetSearch/index.test.tsx +++ b/app/components/UI/AssetSearch/index.test.tsx @@ -52,7 +52,6 @@ jest.mock('../../../core/Engine', () => ({ ], }, }, - preventPollingOnNetworkRestart: false, }, }, })); diff --git a/app/components/UI/Bridge/components/InputStepper/InputStepperDescriptionRow.test.tsx b/app/components/UI/Bridge/components/InputStepper/InputStepperDescriptionRow.test.tsx new file mode 100644 index 00000000000..06f14447c70 --- /dev/null +++ b/app/components/UI/Bridge/components/InputStepper/InputStepperDescriptionRow.test.tsx @@ -0,0 +1,326 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { InputStepperDescriptionRow } from './InputStepperDescriptionRow'; +import { InputStepperDescriptionType } from './constants'; +import { + IconColor, + IconName, + IconSize, + TextColor, +} from '@metamask/design-system-react-native'; + +describe('InputStepperDescriptionRow', () => { + it('renders correct style when type is WARNING', () => { + const description = { + type: InputStepperDescriptionType.WARNING, + message: 'Warning message', + color: TextColor.WarningDefault, + icon: { + name: IconName.Warning, + size: IconSize.Sm, + color: IconColor.WarningDefault, + }, + }; + + const { toJSON } = render( + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders correct style when type is ERROR', () => { + const description = { + type: InputStepperDescriptionType.ERROR, + message: 'Error message', + color: TextColor.ErrorDefault, + icon: { + name: IconName.Danger, + size: IconSize.Sm, + color: IconColor.ErrorDefault, + }, + }; + + const { toJSON } = render( + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders provided message', () => { + const description = { + type: InputStepperDescriptionType.WARNING, + message: 'This is a custom warning message', + color: TextColor.WarningDefault, + }; + + const { getByTestId } = render( + , + ); + + const messageElement = getByTestId('input-text-description-message'); + expect(messageElement.props.children).toBe( + 'This is a custom warning message', + ); + }); + + it('renders icon when provided', () => { + const description = { + type: InputStepperDescriptionType.WARNING, + message: 'Warning with icon', + color: TextColor.WarningDefault, + icon: { + name: IconName.Warning, + size: IconSize.Md, + color: IconColor.WarningDefault, + }, + }; + + const { toJSON, getByTestId } = render( + , + ); + + const icon = getByTestId('input-stepper-description-icon'); + expect(icon).toBeTruthy(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('does not render icon when not provided', () => { + const description = { + type: InputStepperDescriptionType.WARNING, + message: 'Warning without icon', + color: TextColor.WarningDefault, + }; + + const { queryByTestId } = render( + , + ); + + const icon = queryByTestId('input-stepper-description-icon'); + expect(icon).toBeNull(); + }); + + it('renders correct icon style based on provided icon config', () => { + const description = { + type: InputStepperDescriptionType.ERROR, + message: 'Error with custom icon', + color: TextColor.ErrorDefault, + icon: { + name: IconName.Danger, + size: IconSize.Lg, + color: IconColor.ErrorDefault, + }, + }; + + const { toJSON, getByTestId } = render( + , + ); + + const icon = getByTestId('input-stepper-description-icon'); + expect(icon).toBeTruthy(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders correct message color based on provided color property', () => { + const warningDescription = { + type: InputStepperDescriptionType.WARNING, + message: 'Warning message', + color: TextColor.WarningDefault, + }; + + const errorDescription = { + type: InputStepperDescriptionType.ERROR, + message: 'Error message', + color: TextColor.ErrorDefault, + }; + + const { toJSON: warningJSON } = render( + , + ); + + const { toJSON: errorJSON } = render( + , + ); + + expect(warningJSON()).toMatchSnapshot('warning color'); + expect(errorJSON()).toMatchSnapshot('error color'); + }); + + it('does not render component if no description prop is provided', () => { + const { queryByTestId, toJSON } = render( + , + ); + + const descriptionRow = queryByTestId('input-stepper-description-row'); + expect(descriptionRow).toBeNull(); + expect(toJSON()).toBeNull(); + }); + + describe('edge cases', () => { + it('renders with empty message', () => { + const description = { + type: InputStepperDescriptionType.WARNING, + message: '', + color: TextColor.WarningDefault, + }; + + const { getByTestId } = render( + , + ); + + const messageElement = getByTestId('input-text-description-message'); + expect(messageElement.props.children).toBe(''); + }); + + it('renders with very long message', () => { + const longMessage = + 'This is a very long warning message that should still render correctly without breaking the layout or causing any issues with the component rendering.'; + const description = { + type: InputStepperDescriptionType.WARNING, + message: longMessage, + color: TextColor.WarningDefault, + }; + + const { toJSON } = render( + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders warning without icon', () => { + const description = { + type: InputStepperDescriptionType.WARNING, + message: 'Warning message without icon', + color: TextColor.WarningDefault, + }; + + const { toJSON } = render( + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders error without icon', () => { + const description = { + type: InputStepperDescriptionType.ERROR, + message: 'Error message without icon', + color: TextColor.ErrorDefault, + }; + + const { toJSON } = render( + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); + }); + + describe('different icon configurations', () => { + it('renders with small icon size', () => { + const description = { + type: InputStepperDescriptionType.WARNING, + message: 'Warning with small icon', + color: TextColor.WarningDefault, + icon: { + name: IconName.Warning, + size: IconSize.Sm, + color: IconColor.WarningDefault, + }, + }; + + const { toJSON } = render( + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders with medium icon size', () => { + const description = { + type: InputStepperDescriptionType.ERROR, + message: 'Error with medium icon', + color: TextColor.ErrorDefault, + icon: { + name: IconName.Danger, + size: IconSize.Md, + color: IconColor.ErrorDefault, + }, + }; + + const { toJSON } = render( + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders with large icon size', () => { + const description = { + type: InputStepperDescriptionType.ERROR, + message: 'Error with large icon', + color: TextColor.ErrorDefault, + icon: { + name: IconName.Danger, + size: IconSize.Lg, + color: IconColor.ErrorDefault, + }, + }; + + const { toJSON } = render( + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders with different icon names', () => { + const infoDescription = { + type: InputStepperDescriptionType.WARNING, + message: 'Info message', + color: TextColor.WarningDefault, + icon: { + name: IconName.Info, + size: IconSize.Sm, + color: IconColor.InfoDefault, + }, + }; + + const { toJSON } = render( + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); + }); + + describe('layout and structure', () => { + it('renders complete component with all props', () => { + const description = { + type: InputStepperDescriptionType.WARNING, + message: 'Complete warning message', + color: TextColor.WarningDefault, + icon: { + name: IconName.Warning, + size: IconSize.Md, + color: IconColor.WarningDefault, + }, + }; + + const { getByTestId, toJSON } = render( + , + ); + + const descriptionRow = getByTestId('input-stepper-description-row'); + const icon = getByTestId('input-stepper-description-icon'); + const message = getByTestId('input-text-description-message'); + + expect(descriptionRow).toBeTruthy(); + expect(icon).toBeTruthy(); + expect(message).toBeTruthy(); + expect(toJSON()).toMatchSnapshot(); + }); + }); +}); diff --git a/app/components/UI/Bridge/components/InputStepper/InputStepperDescriptionRow.tsx b/app/components/UI/Bridge/components/InputStepper/InputStepperDescriptionRow.tsx new file mode 100644 index 00000000000..09582b69e17 --- /dev/null +++ b/app/components/UI/Bridge/components/InputStepper/InputStepperDescriptionRow.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { View } from 'react-native'; +import { + FontWeight, + Icon, + Text, + TextVariant, +} from '@metamask/design-system-react-native'; +import { inputStepperDescriptionRow } from './styles'; +import { InputStepperProps } from './types'; + +interface InputStepperDescriptionRowProps { + description: InputStepperProps['description']; +} + +export const InputStepperDescriptionRow = ({ + description, +}: InputStepperDescriptionRowProps) => { + if (!description) { + return null; + } + + return ( + + {description.icon && ( + + + + )} + + + {description.message} + + + + ); +}; diff --git a/app/components/UI/Bridge/components/InputStepper/__snapshots__/InputStepperDescriptionRow.test.tsx.snap b/app/components/UI/Bridge/components/InputStepper/__snapshots__/InputStepperDescriptionRow.test.tsx.snap new file mode 100644 index 00000000000..b5be21c3748 --- /dev/null +++ b/app/components/UI/Bridge/components/InputStepper/__snapshots__/InputStepperDescriptionRow.test.tsx.snap @@ -0,0 +1,756 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`InputStepperDescriptionRow different icon configurations renders with different icon names 1`] = ` + + + + + + + Info message + + + +`; + +exports[`InputStepperDescriptionRow different icon configurations renders with large icon size 1`] = ` + + + + + + + Error with large icon + + + +`; + +exports[`InputStepperDescriptionRow different icon configurations renders with medium icon size 1`] = ` + + + + + + + Error with medium icon + + + +`; + +exports[`InputStepperDescriptionRow different icon configurations renders with small icon size 1`] = ` + + + + + + + Warning with small icon + + + +`; + +exports[`InputStepperDescriptionRow edge cases renders error without icon 1`] = ` + + + + Error message without icon + + + +`; + +exports[`InputStepperDescriptionRow edge cases renders warning without icon 1`] = ` + + + + Warning message without icon + + + +`; + +exports[`InputStepperDescriptionRow edge cases renders with very long message 1`] = ` + + + + This is a very long warning message that should still render correctly without breaking the layout or causing any issues with the component rendering. + + + +`; + +exports[`InputStepperDescriptionRow layout and structure renders complete component with all props 1`] = ` + + + + + + + Complete warning message + + + +`; + +exports[`InputStepperDescriptionRow renders correct icon style based on provided icon config 1`] = ` + + + + + + + Error with custom icon + + + +`; + +exports[`InputStepperDescriptionRow renders correct message color based on provided color property: error color 1`] = ` + + + + Error message + + + +`; + +exports[`InputStepperDescriptionRow renders correct message color based on provided color property: warning color 1`] = ` + + + + Warning message + + + +`; + +exports[`InputStepperDescriptionRow renders correct style when type is ERROR 1`] = ` + + + + + + + Error message + + + +`; + +exports[`InputStepperDescriptionRow renders correct style when type is WARNING 1`] = ` + + + + + + + Warning message + + + +`; + +exports[`InputStepperDescriptionRow renders icon when provided 1`] = ` + + + + + + + Warning with icon + + + +`; diff --git a/app/components/UI/Bridge/components/InputStepper/__snapshots__/index.test.tsx.snap b/app/components/UI/Bridge/components/InputStepper/__snapshots__/index.test.tsx.snap new file mode 100644 index 00000000000..dccd6f752e7 --- /dev/null +++ b/app/components/UI/Bridge/components/InputStepper/__snapshots__/index.test.tsx.snap @@ -0,0 +1,2704 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`InputStepper complete component snapshot renders complete component correctly 1`] = ` + + + + + + + + + + + + + + % + + + + + + + + + + +`; + +exports[`InputStepper complete component snapshot renders with description 1`] = ` + + + + + + + + + + + + + + + + + + + +`; + +exports[`InputStepper description row should render InputStepperDescriptionRow 1`] = ` + + + + + + + + + + + + + + + + + + + +`; + +exports[`InputStepper input renders correct style when value character length is less or equal to 10 1`] = ` + + + + + + + + + + + + + + + + + + + +`; + +exports[`InputStepper input renders correct style when value character length is less or equal to 15 1`] = ` + + + + + + + + + + + + + + + + + + + +`; + +exports[`InputStepper input renders correct style when value character length is less or equal to 20 1`] = ` + + + + + + + + + + + + + + + + + + + +`; + +exports[`InputStepper input renders correct style when value character length is less or equal to 25 1`] = ` + + + + + + + + + + + + + + + + + + + +`; + +exports[`InputStepper input renders correct style when value character length is more than 25 1`] = ` + + + + + + + + + + + + + + + + + + + +`; + +exports[`InputStepper minus button renders correct style of minus button when disabled 1`] = ` + + + + + + + + + + + + + + + + + + + +`; + +exports[`InputStepper minus button renders correct style of minus button when enabled 1`] = ` + + + + + + + + + + + + + + + + + + + +`; + +exports[`InputStepper plus button renders correct style of plus button when disabled 1`] = ` + + + + + + + + + + + + + + + + + + + +`; + +exports[`InputStepper plus button renders correct style of plus button when enabled 1`] = ` + + + + + + + + + + + + + + + + + + + +`; diff --git a/app/components/UI/Bridge/components/InputStepper/constants.ts b/app/components/UI/Bridge/components/InputStepper/constants.ts new file mode 100644 index 00000000000..a1ae336d189 --- /dev/null +++ b/app/components/UI/Bridge/components/InputStepper/constants.ts @@ -0,0 +1,4 @@ +export enum InputStepperDescriptionType { + WARNING = 'warning', + ERROR = 'error', +} diff --git a/app/components/UI/Bridge/components/InputStepper/index.test.tsx b/app/components/UI/Bridge/components/InputStepper/index.test.tsx new file mode 100644 index 00000000000..00d6db0a23f --- /dev/null +++ b/app/components/UI/Bridge/components/InputStepper/index.test.tsx @@ -0,0 +1,326 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { InputStepper } from './index'; +import { InputStepperDescriptionType } from './constants'; +import { + IconColor, + IconName, + IconSize, + TextColor, +} from '@metamask/design-system-react-native'; + +jest.mock('./InputStepperDescriptionRow', () => ({ + InputStepperDescriptionRow: () => null, +})); + +describe('InputStepper', () => { + const defaultProps = { + value: '5', + onIncrease: jest.fn(), + onDecrease: jest.fn(), + minAmount: 0, + maxAmount: 100, + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('input', () => { + it('renders formatted passed value', () => { + const { getByTestId } = render( + , + ); + + const input = getByTestId('input-stepper-input'); + expect(input.props.value).toBe('1,234.56'); + }); + + it('renders postValue when provided', () => { + const { getByTestId } = render( + , + ); + + const postValue = getByTestId('input-stepper-post-value'); + expect(postValue).toBeTruthy(); + }); + + it('does not render postValue when not provided', () => { + const { queryByTestId } = render(); + + const postValue = queryByTestId('input-stepper-post-value'); + expect(postValue).toBeNull(); + }); + + it('default placeholder should be zero', () => { + const { getByTestId } = render(); + + const input = getByTestId('input-stepper-input'); + expect(input.props.placeholder).toBe('0'); + }); + + it('input should have autofocus', () => { + const { getByTestId } = render(); + + const input = getByTestId('input-stepper-input'); + expect(input.props.autoFocus).toBe(true); + }); + + it('renders correct style when value character length is less or equal to 10', () => { + const { toJSON } = render( + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders correct style when value character length is less or equal to 15', () => { + const { toJSON } = render( + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders correct style when value character length is less or equal to 20', () => { + const { toJSON } = render( + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders correct style when value character length is less or equal to 25', () => { + const { toJSON } = render( + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders correct style when value character length is more than 25', () => { + const { toJSON } = render( + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders custom placeholder if provided', () => { + const { getByTestId } = render( + , + ); + + const input = getByTestId('input-stepper-input'); + expect(input.props.placeholder).toBe('Enter amount'); + }); + }); + + describe('minus button', () => { + it('calls onDecrease callback when value is more than minAmount', () => { + const onDecrease = jest.fn(); + const { getByTestId } = render( + , + ); + + const minusButton = getByTestId('input-stepper-minus-button'); + fireEvent.press(minusButton); + + expect(onDecrease).toHaveBeenCalledTimes(1); + }); + + it('minus button is disabled when value is equal or less than minAmount', () => { + const { getByTestId } = render( + , + ); + + const minusButton = getByTestId('input-stepper-minus-button'); + expect(minusButton.props.accessibilityState.disabled).toBe(true); + }); + + it('renders correct style of minus button when enabled', () => { + const { toJSON } = render(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders correct style of minus button when disabled', () => { + const { toJSON } = render( + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); + }); + + describe('plus button', () => { + it('calls onIncrease callback when value is less than maxAmount', () => { + const onIncrease = jest.fn(); + const { getByTestId } = render( + , + ); + + const plusButton = getByTestId('input-stepper-plus-button'); + fireEvent.press(plusButton); + + expect(onIncrease).toHaveBeenCalledTimes(1); + }); + + it('plus button is disabled when value is equal or more than maxAmount', () => { + const { getByTestId } = render( + , + ); + + const plusButton = getByTestId('input-stepper-plus-button'); + expect(plusButton.props.accessibilityState.disabled).toBe(true); + }); + + it('renders correct style of plus button when enabled', () => { + const { toJSON } = render(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders correct style of plus button when disabled', () => { + const { toJSON } = render( + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); + }); + + describe('description row', () => { + it('should render InputStepperDescriptionRow', () => { + const description = { + type: InputStepperDescriptionType.WARNING, + message: 'Warning message', + color: TextColor.WarningDefault, + icon: { + name: IconName.Warning, + size: IconSize.Sm, + color: IconColor.WarningDefault, + }, + }; + + const { toJSON } = render( + , + ); + + // Since InputStepperDescriptionRow is mocked to return null, + // we verify the component renders without it in the snapshot + expect(toJSON()).toMatchSnapshot(); + }); + }); + + describe('button press interactions', () => { + it('handles minus button press events', () => { + const onDecrease = jest.fn(); + const { getByTestId } = render( + , + ); + + const minusButton = getByTestId('input-stepper-minus-button'); + + // Verify button responds to press events without errors + fireEvent(minusButton, 'pressIn'); + fireEvent(minusButton, 'pressOut'); + fireEvent.press(minusButton); + + expect(onDecrease).toHaveBeenCalledTimes(1); + }); + + it('handles plus button press events', () => { + const onIncrease = jest.fn(); + const { getByTestId } = render( + , + ); + + const plusButton = getByTestId('input-stepper-plus-button'); + + // Verify button responds to press events without errors + fireEvent(plusButton, 'pressIn'); + fireEvent(plusButton, 'pressOut'); + fireEvent.press(plusButton); + + expect(onIncrease).toHaveBeenCalledTimes(1); + }); + }); + + describe('edge cases', () => { + it('handles empty string value', () => { + const { getByTestId } = render( + , + ); + + const input = getByTestId('input-stepper-input'); + expect(input.props.value).toBe(''); + }); + + it('handles zero value', () => { + const { getByTestId } = render( + , + ); + + const input = getByTestId('input-stepper-input'); + expect(input.props.value).toBe('0'); + }); + + it('handles decimal values', () => { + const { getByTestId } = render( + , + ); + + const input = getByTestId('input-stepper-input'); + expect(input.props.value).toBe('12.5'); + }); + + it('minus button disabled at exact minAmount', () => { + const { getByTestId } = render( + , + ); + + const minusButton = getByTestId('input-stepper-minus-button'); + expect(minusButton.props.accessibilityState.disabled).toBe(true); + }); + + it('plus button disabled at exact maxAmount', () => { + const { getByTestId } = render( + , + ); + + const plusButton = getByTestId('input-stepper-plus-button'); + expect(plusButton.props.accessibilityState.disabled).toBe(true); + }); + }); + + describe('complete component snapshot', () => { + it('renders complete component correctly', () => { + const { toJSON } = render( + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders with description', () => { + const { toJSON } = render( + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); + }); +}); diff --git a/app/components/UI/Bridge/components/InputStepper/index.tsx b/app/components/UI/Bridge/components/InputStepper/index.tsx new file mode 100644 index 00000000000..3eac5fc2d1b --- /dev/null +++ b/app/components/UI/Bridge/components/InputStepper/index.tsx @@ -0,0 +1,88 @@ +import React, { useMemo, useState } from 'react'; +import { View } from 'react-native'; +import Input from '../../../../../component-library/components/Form/TextField/foundation/Input'; +import { + ButtonIcon, + ButtonIconSize, + IconColor, + IconName, + Text, +} from '@metamask/design-system-react-native'; +import { useStyles } from '../../../../../component-library/hooks'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { inputStepperStyles } from './styles'; +import { calculateInputFontSize } from '../../utils/calculateInputFontSize'; +import { InputStepperProps } from './types'; +import { InputStepperDescriptionRow } from './InputStepperDescriptionRow'; +import { formatAmountWithLocaleSeparators } from '../../utils/formatAmountWithLocaleSeparators'; + +export const InputStepper = ({ + value, + onDecrease, + onIncrease, + description, + minAmount, + maxAmount, + postValue, + placeholder = '0', +}: InputStepperProps) => { + const fontSize = calculateInputFontSize(value.length); + const { styles } = useStyles(inputStepperStyles, { fontSize }); + const tw = useTailwind(); + const [minusPressed, setMinusPressed] = useState(false); + const [plusPressed, setPlusPressed] = useState(false); + const displayedAmount = useMemo( + () => formatAmountWithLocaleSeparators(value), + [value], + ); + + return ( + + + setMinusPressed(true)} + onPressOut={() => setMinusPressed(false)} + onPress={onDecrease} + isDisabled={parseFloat(value) <= minAmount} + testID="input-stepper-minus-button" + /> + + + + + {postValue && ( + + {postValue} + + )} + + setPlusPressed(true)} + onPressOut={() => setPlusPressed(false)} + onPress={onIncrease} + isDisabled={parseFloat(value) >= maxAmount} + testID="input-stepper-plus-button" + /> + + + + ); +}; diff --git a/app/components/UI/Bridge/components/InputStepper/styles.tsx b/app/components/UI/Bridge/components/InputStepper/styles.tsx new file mode 100644 index 00000000000..bd15606162f --- /dev/null +++ b/app/components/UI/Bridge/components/InputStepper/styles.tsx @@ -0,0 +1,44 @@ +import { Theme } from '@metamask/design-tokens'; +import { StyleSheet } from 'react-native'; + +export const inputStepperStyles = ({ + vars, +}: { + vars: { fontSize: number }; + theme: Theme; +}) => + StyleSheet.create({ + container: { + gap: 16, + }, + stepperRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-around', + }, + inputRow: { + flexDirection: 'row', + width: 100, + justifyContent: 'center', + }, + input: { + borderWidth: 0, + lineHeight: vars.fontSize * 1.25, + height: vars.fontSize * 1.25, + fontSize: vars.fontSize, + }, + }); + +export const inputStepperDescriptionRow = StyleSheet.create({ + descriptionRow: { + flexDirection: 'row', + width: '75%', + marginHorizontal: 'auto', + }, + descriptionTextWrapper: { + flex: 1, + }, + descriptionText: { + textAlign: 'center', + }, +}); diff --git a/app/components/UI/Bridge/components/InputStepper/types.ts b/app/components/UI/Bridge/components/InputStepper/types.ts new file mode 100644 index 00000000000..9d94a709c1c --- /dev/null +++ b/app/components/UI/Bridge/components/InputStepper/types.ts @@ -0,0 +1,25 @@ +import { + IconColor, + IconName, + IconSize, + TextColor, +} from '@metamask/design-system-react-native'; + +export interface InputStepperProps { + value: string; + onIncrease: () => void; + onDecrease: () => void; + description?: { + message: string; + color: TextColor; + icon?: { + name: IconName; + size: IconSize; + color: IconColor; + }; + }; + minAmount: number; + maxAmount: number; + postValue?: string; + placeholder?: string; +} diff --git a/app/components/UI/Bridge/components/LegacySwapsLiveness.test.ts b/app/components/UI/Bridge/components/LegacySwapsLiveness.test.ts deleted file mode 100644 index 21c21a07134..00000000000 --- a/app/components/UI/Bridge/components/LegacySwapsLiveness.test.ts +++ /dev/null @@ -1,397 +0,0 @@ -import { renderHook, act } from '@testing-library/react-hooks'; -import { AppState, AppStateStatus } from 'react-native'; -import { useDispatch, useSelector } from 'react-redux'; -import { swapsUtils, type FeatureFlags } from '@metamask/swaps-controller'; -import LegacySwapLiveness from './LegacySwapsLiveness'; -import { setSwapsLiveness } from '../../../../reducers/swaps'; -import Logger from '../../../../util/Logger'; -import { isBridgeAllowed } from '../utils'; - -// Mock dependencies -jest.mock('react-redux', () => ({ - useDispatch: jest.fn(), - useSelector: jest.fn(), -})); - -jest.mock('react-native', () => ({ - AppState: { - addEventListener: jest.fn(), - }, -})); - -jest.mock('@metamask/swaps-controller', () => ({ - swapsUtils: { - fetchSwapsFeatureFlags: jest.fn(), - }, -})); - -jest.mock('../../../../reducers/swaps', () => ({ - setSwapsLiveness: jest.fn(), -})); - -jest.mock('../../../../util/Logger', () => ({ - error: jest.fn(), -})); - -jest.mock('../utils', () => ({ - isBridgeAllowed: jest.fn(), -})); - -// Mock the bridge slice to break the import chain that causes selector issues -jest.mock('../../../../core/redux/slices/bridge', () => ({ - selectIsSwapsLive: jest.fn(), -})); - -jest.mock('../../../../selectors/networkController', () => ({ - selectEvmChainId: jest.fn(), -})); - -// Mock useInterval hook -const mockUseInterval = jest.fn(); -jest.mock('../../../hooks/useInterval', () => ({ - __esModule: true, - default: (callback: () => void, options: { delay: number | null }) => - mockUseInterval(callback, options), -})); - -jest.mock('../../../../core/AppConstants', () => ({ - __esModule: true, - default: { - SWAPS: { - LIVENESS_POLLING_FREQUENCY: 10000, - CLIENT_ID: 'test-client-id', - }, - }, -})); - -const mockUseDispatch = useDispatch as jest.MockedFunction; -const mockUseSelector = useSelector as jest.MockedFunction; -const mockFetchSwapsFeatureFlags = - swapsUtils.fetchSwapsFeatureFlags as jest.MockedFunction< - typeof swapsUtils.fetchSwapsFeatureFlags - >; -const mockSetSwapsLiveness = setSwapsLiveness as jest.MockedFunction< - typeof setSwapsLiveness ->; -const mockIsBridgeAllowed = isBridgeAllowed as jest.MockedFunction< - typeof isBridgeAllowed ->; -const mockLoggerError = Logger.error as jest.MockedFunction< - typeof Logger.error ->; -const mockAppStateAddEventListener = - AppState.addEventListener as jest.MockedFunction< - typeof AppState.addEventListener - >; - -describe('LegacySwapLiveness', () => { - const mockDispatch = jest.fn(); - const mockChainId = '0x1'; - // Mock feature flags - cast to unknown first then to FeatureFlags to avoid type mismatch - const mockFeatureFlags = { - ethereum: { - mobile_active: true, - extension_active: true, - fallbackToV1: false, - mobileActive: true, - extensionActive: true, - mobileActiveIOS: true, - mobileActiveAndroid: true, - }, - smart_transactions: { - mobile_active: false, - extension_active: false, - }, - smartTransactions: { - mobileActive: false, - extensionActive: false, - mobileActiveIOS: false, - mobileActiveAndroid: false, - }, - } as unknown as FeatureFlags; - const mockRemoveListener = jest.fn(); - let appStateChangeHandler: ((state: AppStateStatus) => void) | null = null; - - beforeEach(() => { - jest.clearAllMocks(); - - mockUseDispatch.mockReturnValue(mockDispatch); - mockSetSwapsLiveness.mockImplementation((chainId, featureFlags) => ({ - type: 'SWAPS_SET_LIVENESS', - payload: { chainId, featureFlags }, - })); - - // Default selector behavior based on call order - // Component calls useSelector twice: 1st for chainId, 2nd for isLive - // Odd calls (1, 3, ...) are chainId, even calls (2, 4, ...) are isLive - let selectorCallCount = 0; - mockUseSelector.mockImplementation(() => { - selectorCallCount++; - if (selectorCallCount % 2 === 1) { - return mockChainId; // chainId (1st, 3rd, ... call) - } - return false; // isLive - default to false (2nd, 4th, ... call) - }); - - mockIsBridgeAllowed.mockReturnValue(true); - mockFetchSwapsFeatureFlags.mockResolvedValue(mockFeatureFlags); - - appStateChangeHandler = null; - mockAppStateAddEventListener.mockImplementation((event, handler) => { - if (event === 'change') { - appStateChangeHandler = handler; - } - return { remove: mockRemoveListener }; - }); - }); - - it('returns null (renders nothing)', () => { - mockUseSelector - .mockReturnValueOnce(mockChainId) // selectEvmChainId - .mockReturnValueOnce(false); // selectIsSwapsLive - - const { result } = renderHook(() => LegacySwapLiveness()); - - expect(result.current).toBeNull(); - }); - - it('fetches liveness on mount', async () => { - mockUseSelector - .mockReturnValueOnce(mockChainId) // selectEvmChainId - .mockReturnValueOnce(false); // selectIsSwapsLive - - renderHook(() => LegacySwapLiveness()); - - await act(async () => { - await Promise.resolve(); - }); - - expect(mockFetchSwapsFeatureFlags).toHaveBeenCalledWith( - mockChainId, - 'test-client-id', - ); - expect(mockDispatch).toHaveBeenCalledWith( - mockSetSwapsLiveness(mockChainId, mockFeatureFlags), - ); - }); - - it('fetches liveness when chainId changes and bridge is allowed but not live', async () => { - const newChainId = '0xa'; - mockUseSelector - .mockReturnValueOnce(mockChainId) // initial chainId - .mockReturnValueOnce(false) // initial isLive - .mockReturnValueOnce(newChainId) // new chainId - .mockReturnValueOnce(false); // isLive still false - - const { rerender } = renderHook(() => LegacySwapLiveness()); - - await act(async () => { - await Promise.resolve(); - }); - - // Clear calls from initial render - mockFetchSwapsFeatureFlags.mockClear(); - mockDispatch.mockClear(); - - // Rerender with new chainId - rerender(); - - await act(async () => { - await Promise.resolve(); - }); - - expect(mockFetchSwapsFeatureFlags).toHaveBeenCalledWith( - newChainId, - 'test-client-id', - ); - }); - - it('does not fetch liveness when chainId changes but bridge is not allowed', async () => { - mockIsBridgeAllowed.mockReturnValue(false); - mockUseSelector.mockReturnValueOnce(mockChainId).mockReturnValueOnce(false); - - renderHook(() => LegacySwapLiveness()); - - await act(async () => { - await Promise.resolve(); - }); - - // Clear and rerender - mockFetchSwapsFeatureFlags.mockClear(); - mockUseSelector.mockReturnValueOnce('0xa').mockReturnValueOnce(false); - - // The chainId change effect checks isBridgeAllowed - expect(mockIsBridgeAllowed).toHaveBeenCalled(); - }); - - it('does not fetch liveness when already live', async () => { - mockUseSelector.mockReturnValueOnce(mockChainId).mockReturnValueOnce(true); // already live - - renderHook(() => LegacySwapLiveness()); - - // Clear the initial mount call - await act(async () => { - await Promise.resolve(); - }); - mockFetchSwapsFeatureFlags.mockClear(); - - // The chainId change effect should not trigger fetch since already live - mockIsBridgeAllowed.mockReturnValue(true); - mockUseSelector.mockReturnValueOnce(mockChainId).mockReturnValueOnce(true); - - // No additional fetch should be made in chainId change effect - expect(mockFetchSwapsFeatureFlags).not.toHaveBeenCalled(); - }); - - it('sets up AppState listener when bridge is allowed', () => { - mockUseSelector.mockReturnValueOnce(mockChainId).mockReturnValueOnce(false); - mockIsBridgeAllowed.mockReturnValue(true); - - renderHook(() => LegacySwapLiveness()); - - expect(mockAppStateAddEventListener).toHaveBeenCalledWith( - 'change', - expect.any(Function), - ); - }); - - it('does not set up AppState listener when bridge is not allowed', () => { - mockUseSelector.mockReturnValueOnce(mockChainId).mockReturnValueOnce(false); - mockIsBridgeAllowed.mockReturnValue(false); - - renderHook(() => LegacySwapLiveness()); - - expect(mockAppStateAddEventListener).not.toHaveBeenCalled(); - }); - - it('removes AppState listener on unmount', () => { - mockUseSelector.mockReturnValueOnce(mockChainId).mockReturnValueOnce(false); - mockIsBridgeAllowed.mockReturnValue(true); - - const { unmount } = renderHook(() => LegacySwapLiveness()); - - expect(mockAppStateAddEventListener).toHaveBeenCalled(); - - unmount(); - - expect(mockRemoveListener).toHaveBeenCalled(); - }); - - it('checks liveness when app becomes active and not already live', async () => { - mockUseSelector.mockReturnValueOnce(mockChainId).mockReturnValueOnce(false); // not live - mockIsBridgeAllowed.mockReturnValue(true); - - renderHook(() => LegacySwapLiveness()); - - await act(async () => { - await Promise.resolve(); - }); - - // Clear initial mount call - mockFetchSwapsFeatureFlags.mockClear(); - mockDispatch.mockClear(); - - // Simulate app becoming active - await act(async () => { - appStateChangeHandler?.('active'); - await Promise.resolve(); - }); - - expect(mockFetchSwapsFeatureFlags).toHaveBeenCalledWith( - mockChainId, - 'test-client-id', - ); - }); - - it('does not check liveness when app goes to background', async () => { - mockUseSelector.mockReturnValueOnce(mockChainId).mockReturnValueOnce(false); - mockIsBridgeAllowed.mockReturnValue(true); - - renderHook(() => LegacySwapLiveness()); - - await act(async () => { - await Promise.resolve(); - }); - - // Clear initial mount call - mockFetchSwapsFeatureFlags.mockClear(); - - // Simulate app going to background - await act(async () => { - appStateChangeHandler?.('background'); - await Promise.resolve(); - }); - - expect(mockFetchSwapsFeatureFlags).not.toHaveBeenCalled(); - }); - - it('handles fetch error gracefully and sets liveness to null', async () => { - const mockError = new Error('Network error'); - mockFetchSwapsFeatureFlags.mockRejectedValue(mockError); - mockUseSelector.mockReturnValueOnce(mockChainId).mockReturnValueOnce(false); - - renderHook(() => LegacySwapLiveness()); - - await act(async () => { - await Promise.resolve(); - }); - - expect(mockLoggerError).toHaveBeenCalledWith( - mockError, - 'Swaps: error while fetching swaps liveness', - ); - expect(mockDispatch).toHaveBeenCalledWith( - mockSetSwapsLiveness(mockChainId, null), - ); - }); - - describe('polling interval', () => { - it('calls useInterval with polling delay when bridge is allowed and not live', () => { - mockUseSelector - .mockReturnValueOnce(mockChainId) - .mockReturnValueOnce(false); // not live - mockIsBridgeAllowed.mockReturnValue(true); - - renderHook(() => LegacySwapLiveness()); - - // Verify useInterval was called with the polling frequency - expect(mockUseInterval).toHaveBeenCalledWith(expect.any(Function), { - delay: 10000, - }); - }); - - it('calls useInterval with null delay when bridge is not allowed', () => { - mockUseSelector - .mockReturnValueOnce(mockChainId) - .mockReturnValueOnce(false); - mockIsBridgeAllowed.mockReturnValue(false); - - renderHook(() => LegacySwapLiveness()); - - // Verify useInterval was called with null delay (disabled) - expect(mockUseInterval).toHaveBeenCalledWith(expect.any(Function), { - delay: null, - }); - }); - }); - - describe('setLiveness callback', () => { - it('dispatches setSwapsLiveness with chainId and featureFlags', async () => { - mockUseSelector - .mockReturnValueOnce(mockChainId) - .mockReturnValueOnce(false); - - renderHook(() => LegacySwapLiveness()); - - await act(async () => { - await Promise.resolve(); - }); - - expect(mockSetSwapsLiveness).toHaveBeenCalledWith( - mockChainId, - mockFeatureFlags, - ); - expect(mockDispatch).toHaveBeenCalled(); - }); - }); -}); diff --git a/app/components/UI/Bridge/components/LegacySwapsLiveness.ts b/app/components/UI/Bridge/components/LegacySwapsLiveness.ts deleted file mode 100644 index f522dc76d4f..00000000000 --- a/app/components/UI/Bridge/components/LegacySwapsLiveness.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { FeatureFlags, swapsUtils } from '@metamask/swaps-controller'; -import { useCallback, useEffect } from 'react'; -import { AppState, AppStateStatus } from 'react-native'; -import { useDispatch, useSelector } from 'react-redux'; -import AppConstants from '../../../../core/AppConstants'; -import { setSwapsLiveness } from '../../../../reducers/swaps'; -import Logger from '../../../../util/Logger'; -import useInterval from '../../../hooks/useInterval'; -import { RootState } from '../../../../reducers'; -import { selectIsSwapsLive } from '../../../../core/redux/slices/bridge'; -import { selectEvmChainId } from '../../../../selectors/networkController'; -import { isBridgeAllowed } from '../utils'; - -const POLLING_FREQUENCY = AppConstants.SWAPS.LIVENESS_POLLING_FREQUENCY; - -function LegacySwapLiveness() { - const chainId = useSelector(selectEvmChainId); - const isLive = useSelector((state: RootState) => - selectIsSwapsLive(state, chainId), - ); - const dispatch = useDispatch(); - const setLiveness = useCallback( - (_chainId: string, featureFlags?: FeatureFlags | null) => { - dispatch(setSwapsLiveness(_chainId, featureFlags)); - }, - [dispatch], - ); - const checkLiveness = useCallback(async () => { - try { - const featureFlags = await swapsUtils.fetchSwapsFeatureFlags( - chainId, - AppConstants.SWAPS.CLIENT_ID, - ); - - setLiveness(chainId, featureFlags); - } catch (error) { - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - Logger.error(error as any, 'Swaps: error while fetching swaps liveness'); - setLiveness(chainId, null); - } - }, [setLiveness, chainId]); - - // Need to check swap feature flags once on load, so we can use it for STX - useEffect(() => { - checkLiveness(); - }, [checkLiveness]); - - useEffect(() => { - if (isBridgeAllowed(chainId) && !isLive) { - checkLiveness(); - } - }, [chainId, checkLiveness, isLive]); - // Check on AppState change - const appStateHandler = useCallback( - (newState: AppStateStatus) => { - if (!isLive && newState === 'active') { - checkLiveness(); - } - }, - [checkLiveness, isLive], - ); - useEffect(() => { - if (isBridgeAllowed(chainId)) { - const appStateListener = AppState.addEventListener( - 'change', - appStateHandler, - ); - return () => { - appStateListener.remove(); - }; - } - }, [appStateHandler, chainId]); - // Check on interval - useInterval( - async () => { - checkLiveness(); - }, - { delay: isBridgeAllowed(chainId) && !isLive ? POLLING_FREQUENCY : null }, - ); - return null; -} - -export default LegacySwapLiveness; diff --git a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx index 7fed713d55f..a3ece82bded 100644 --- a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx +++ b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx @@ -324,7 +324,11 @@ describe('QuoteDetailsCard', () => { // Check if navigation was called with correct params expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, { - screen: Routes.BRIDGE.MODALS.SLIPPAGE_MODAL, + screen: Routes.BRIDGE.MODALS.DEFAULT_SLIPPAGE_MODAL, + params: { + sourceChainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + destChainId: 'evm:1', + }, }); }); diff --git a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx index 31463ff852d..a1614f50cb2 100644 --- a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx +++ b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx @@ -120,7 +120,11 @@ const QuoteDetailsCard: React.FC = () => { const handleSlippagePress = () => { navigation.navigate(Routes.BRIDGE.MODALS.ROOT, { - screen: Routes.BRIDGE.MODALS.SLIPPAGE_MODAL, + screen: Routes.BRIDGE.MODALS.DEFAULT_SLIPPAGE_MODAL, + params: { + sourceChainId: sourceToken?.chainId, + destChainId: destToken?.chainId, + }, }); }; diff --git a/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.test.tsx b/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.test.tsx new file mode 100644 index 00000000000..99e00e9f1af --- /dev/null +++ b/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.test.tsx @@ -0,0 +1,969 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { CustomSlippageModal } from './CustomSlippageModal'; + +// Mock BottomSheet +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheet', + () => { + const ReactModule = jest.requireActual('react'); + const ReactNative = jest.requireActual('react-native'); + const { View } = ReactNative; + + return { + __esModule: true, + default: ReactModule.forwardRef( + (props: { children: unknown }, _ref: unknown) => ( + {props.children as React.ReactNode} + ), + ), + }; + }, +); + +// Mock HeaderCenter +jest.mock( + '../../../../../component-library/components-temp/HeaderCenter', + () => { + const ReactNative = jest.requireActual('react-native'); + const { View, Text, TouchableOpacity } = ReactNative; + + return { + __esModule: true, + default: (props: { title: string; onClose: () => void }) => ( + + {props.title} + + Close + + + ), + }; + }, +); + +// Mock InputStepper +jest.mock('../InputStepper', () => ({ + InputStepper: jest.fn( + ({ + value, + onIncrease, + onDecrease, + description, + }: { + value: string; + onIncrease: () => void; + onDecrease: () => void; + description: unknown; + }) => { + const ReactNative = jest.requireActual('react-native'); + const { View, Text, TouchableOpacity } = ReactNative; + + return ( + + + - + + {value} + + + + + {description && } + + ); + }, + ), +})); + +// Mock Keypad +jest.mock('../../../../Base/Keypad', () => ({ + __esModule: true, + Keys: { + Back: 'Back', + Period: 'Period', + Digit0: '0', + Digit1: '1', + Digit2: '2', + Digit3: '3', + Digit4: '4', + Digit5: '5', + Digit6: '6', + Digit7: '7', + Digit8: '8', + Digit9: '9', + Initial: 'Initial', + }, + default: jest.fn( + ({ + value, + onChange, + }: { + value: string; + onChange: (data: { + value: string; + valueAsNumber: number; + pressedKey: string; + }) => void; + }) => { + const ReactNative = jest.requireActual('react-native'); + const { View, TouchableOpacity, Text } = ReactNative; + + return ( + + {value} + + onChange({ + value: value + '5', + valueAsNumber: parseFloat(value + '5'), + pressedKey: '5', + }) + } + > + 5 + + + ); + }, + ), +})); + +// Mock hooks +jest.mock('../../hooks/useSlippageConfig', () => ({ + useSlippageConfig: jest.fn(), +})); + +jest.mock('../../hooks/useShouldDisableCustomSlippageConfirm', () => ({ + useShouldDisableCustomSlippageConfirm: jest.fn(), +})); + +jest.mock('../../hooks/useSlippageStepperDescription', () => ({ + useSlippageStepperDescription: jest.fn(), +})); + +jest.mock('../../../../../util/navigation/navUtils', () => ({ + useParams: jest.fn(), +})); + +// Mock Redux +const mockDispatch = jest.fn(); +const mockSelector = jest.fn(); + +jest.mock('react-redux', () => ({ + useDispatch: () => mockDispatch, + useSelector: (selector: (state: unknown) => unknown) => + mockSelector(selector), +})); + +// Mock i18n +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => { + const translations: Record = { + 'bridge.slippage': 'Slippage', + 'bridge.cancel': 'Cancel', + 'bridge.confirm': 'Confirm', + }; + return translations[key] || key; + }), +})); + +import { useSlippageConfig } from '../../hooks/useSlippageConfig'; +import { useShouldDisableCustomSlippageConfirm } from '../../hooks/useShouldDisableCustomSlippageConfirm'; +import { useSlippageStepperDescription } from '../../hooks/useSlippageStepperDescription'; +import { useParams } from '../../../../../util/navigation/navUtils'; +import { InputStepper } from '../InputStepper'; +import Keypad from '../../../../Base/Keypad'; + +const mockUseSlippageConfig = useSlippageConfig as jest.MockedFunction< + typeof useSlippageConfig +>; +const mockUseShouldDisableCustomSlippageConfirm = + useShouldDisableCustomSlippageConfirm as jest.MockedFunction< + typeof useShouldDisableCustomSlippageConfirm + >; +const mockUseSlippageStepperDescription = + useSlippageStepperDescription as jest.MockedFunction< + typeof useSlippageStepperDescription + >; +const mockUseParams = useParams as jest.MockedFunction; +const mockInputStepper = InputStepper as jest.MockedFunction< + typeof InputStepper +>; +const mockKeypad = Keypad as jest.MockedFunction; + +describe('CustomSlippageModal', () => { + const mockSlippageConfig = { + input_step: 0.1, + max_amount: 100, + min_amount: 0, + input_max_decimals: 2, + lower_allowed_slippage_threshold: null, + lower_suggested_slippage_threshold: null, + upper_suggested_slippage_threshold: null, + upper_allowed_slippage_threshold: null, + default_slippage_options: ['auto', '0.5', '2', '3'], + has_custom_slippage_option: true, + }; + + beforeEach(() => { + mockUseSlippageConfig.mockReturnValue(mockSlippageConfig); + mockUseShouldDisableCustomSlippageConfirm.mockReturnValue(false); + mockUseSlippageStepperDescription.mockReturnValue(undefined); + mockUseParams.mockReturnValue({ + sourceChainId: '0x1', + destChainId: undefined, + }); + mockSelector.mockReturnValue(undefined); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('cancel button', () => { + it('closes modal when cancel button is pressed', () => { + const { getByText } = render(); + + const cancelButton = getByText('Cancel'); + fireEvent.press(cancelButton); + + // Verify close was called (ref.onCloseBottomSheet) + // Component should render without errors + expect(cancelButton).toBeTruthy(); + }); + + it('does not dispatch slippage on cancel', () => { + const { getByText } = render(); + + const cancelButton = getByText('Cancel'); + fireEvent.press(cancelButton); + + expect(mockDispatch).not.toHaveBeenCalled(); + }); + }); + + describe('confirm button', () => { + it('updates slippage state and closes modal when confirm is pressed', () => { + mockSelector.mockReturnValue('2.5'); + + const { getByText } = render(); + + const confirmButton = getByText('Confirm'); + fireEvent.press(confirmButton); + + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: expect.stringContaining('setSlippage'), + payload: '2.5', + }), + ); + }); + + it('dispatches current inputAmount value', () => { + mockSelector.mockReturnValue('5'); + + const { getByText } = render(); + + const confirmButton = getByText('Confirm'); + fireEvent.press(confirmButton); + + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + payload: '5', + }), + ); + }); + + it('is disabled when shouldDisableConfirm is true', () => { + mockUseShouldDisableCustomSlippageConfirm.mockReturnValue(true); + + const { toJSON, getByText } = render(); + + // Verify button exists + expect(getByText('Confirm')).toBeTruthy(); + + // Snapshot shows disabled state + expect(toJSON()).toMatchSnapshot('confirm button disabled'); + }); + + it('is enabled when shouldDisableConfirm is false', () => { + mockUseShouldDisableCustomSlippageConfirm.mockReturnValue(false); + + const { toJSON, getByText } = render(); + + // Verify button exists + expect(getByText('Confirm')).toBeTruthy(); + + // Snapshot shows enabled state + expect(toJSON()).toMatchSnapshot('confirm button enabled'); + }); + }); + + describe('plus button (increase)', () => { + it('increases value by input_step when pressed', () => { + mockSelector.mockReturnValue('1'); + + const { getByTestId } = render(); + + const increaseButton = getByTestId('input-stepper-increase'); + fireEvent.press(increaseButton); + + const valueElement = getByTestId('input-stepper-value'); + // 1 + 0.1 = 1.1 + expect(valueElement.props.children).toBe('1.1'); + }); + + it('does not exceed max_amount', () => { + mockSelector.mockReturnValue('99.95'); + + const { getByTestId } = render(); + + const increaseButton = getByTestId('input-stepper-increase'); + fireEvent.press(increaseButton); + + const valueElement = getByTestId('input-stepper-value'); + // 99.95 + 0.1 = 100.05, but capped at max_amount (100) + expect(valueElement.props.children).toBe('100'); + }); + + it('caps at max_amount when exceeding', () => { + mockSelector.mockReturnValue('100'); + + const { getByTestId } = render(); + + const increaseButton = getByTestId('input-stepper-increase'); + fireEvent.press(increaseButton); + + const valueElement = getByTestId('input-stepper-value'); + expect(valueElement.props.children).toBe('100'); + }); + + it('updates hooks after increase', () => { + mockSelector.mockReturnValue('50'); + + const { getByTestId } = render(); + + const increaseButton = getByTestId('input-stepper-increase'); + fireEvent.press(increaseButton); + + // Verify hooks were called with updated inputAmount + expect(mockUseShouldDisableCustomSlippageConfirm).toHaveBeenCalledWith({ + inputAmount: '50.1', + slippageConfig: mockSlippageConfig, + }); + + expect(mockUseSlippageStepperDescription).toHaveBeenCalledWith({ + inputAmount: '50.1', + slippageConfig: mockSlippageConfig, + hasAttemptedToExceedMax: false, + }); + }); + }); + + describe('minus button (decrease)', () => { + it('decreases value by input_step when pressed', () => { + mockSelector.mockReturnValue('2'); + + const { getByTestId } = render(); + + const decreaseButton = getByTestId('input-stepper-decrease'); + fireEvent.press(decreaseButton); + + const valueElement = getByTestId('input-stepper-value'); + // 2 - 0.1 = 1.9 + expect(valueElement.props.children).toBe('1.9'); + }); + + it('does not go below min_amount', () => { + mockSelector.mockReturnValue('0.05'); + + const { getByTestId } = render(); + + const decreaseButton = getByTestId('input-stepper-decrease'); + fireEvent.press(decreaseButton); + + const valueElement = getByTestId('input-stepper-value'); + // 0.05 - 0.1 = -0.05, but capped at min_amount (0) + expect(valueElement.props.children).toBe('0'); + }); + + it('caps at min_amount when going below', () => { + mockSelector.mockReturnValue('0'); + + const { getByTestId } = render(); + + const decreaseButton = getByTestId('input-stepper-decrease'); + fireEvent.press(decreaseButton); + + const valueElement = getByTestId('input-stepper-value'); + expect(valueElement.props.children).toBe('0'); + }); + + it('updates hooks after decrease', () => { + mockSelector.mockReturnValue('50'); + + const { getByTestId } = render(); + + const decreaseButton = getByTestId('input-stepper-decrease'); + fireEvent.press(decreaseButton); + + // Verify hooks were called with updated inputAmount + expect(mockUseShouldDisableCustomSlippageConfirm).toHaveBeenCalledWith({ + inputAmount: '49.9', + slippageConfig: mockSlippageConfig, + }); + + expect(mockUseSlippageStepperDescription).toHaveBeenCalledWith({ + inputAmount: '49.9', + slippageConfig: mockSlippageConfig, + hasAttemptedToExceedMax: false, + }); + }); + }); + + describe('decimal handling', () => { + it('removes redundant trailing zeros', () => { + mockSelector.mockReturnValue('1'); + + const { getByTestId } = render(); + + const increaseButton = getByTestId('input-stepper-increase'); + fireEvent.press(increaseButton); + + const valueElement = getByTestId('input-stepper-value'); + // 1 + 0.1 = 1.1 (not 1.10) + expect(valueElement.props.children).toBe('1.1'); + }); + + it('respects input_max_decimals limit', () => { + mockSelector.mockReturnValue('1.2'); + + const { getByTestId } = render(); + + const increaseButton = getByTestId('input-stepper-increase'); + fireEvent.press(increaseButton); + + const valueElement = getByTestId('input-stepper-value'); + // 1.2 + 0.1 = 1.3 (limited to 2 decimals) + expect(valueElement.props.children).toBe('1.3'); + }); + + it('handles rounding to max decimals', () => { + const configWithHigherStep = { + ...mockSlippageConfig, + input_step: 0.33, + }; + mockUseSlippageConfig.mockReturnValue(configWithHigherStep); + mockSelector.mockReturnValue('1'); + + const { getByTestId } = render(); + + const increaseButton = getByTestId('input-stepper-increase'); + fireEvent.press(increaseButton); + + const valueElement = getByTestId('input-stepper-value'); + // 1 + 0.33 = 1.33, rounded to 2 decimals + expect(valueElement.props.children).toBe('1.33'); + }); + }); + + describe('keypad input handling', () => { + it('accepts valid keypad input', () => { + mockSelector.mockReturnValue('1'); + + const { getByTestId } = render(); + + // Simulate keypad input + const keypadButton = getByTestId('keypad-button-5'); + fireEvent.press(keypadButton); + + const valueElement = getByTestId('keypad-value'); + // Should update to '15' + expect(valueElement.props.children).toBe('15'); + }); + + it('does not change value when exceeding max_amount', () => { + mockSelector.mockReturnValue('100'); + + const { getByTestId } = render(); + + // Try to input more (simulate onChange with value > max) + const keypadButton = getByTestId('keypad-button-5'); + fireEvent.press(keypadButton); + + // Value should remain at 100 (no change) + const valueElement = getByTestId('keypad-value'); + expect(valueElement.props.children).toBe('100'); + }); + + it('sets hasAttemptedToExceedMax when value exceeds max_amount', () => { + mockSelector.mockReturnValue('50'); + + const { getByTestId, rerender } = render(); + + // Get the Keypad onChange handler + const keypadOnChange = mockKeypad.mock.calls[0][0].onChange; + + // Try to input value that exceeds max_amount (e.g., 150) + keypadOnChange({ + value: '150', + valueAsNumber: 150, + pressedKey: '0' as never, + }); + + // Re-render to apply state change + rerender(); + + // Value should remain unchanged (rejected) + const valueElement = getByTestId('input-stepper-value'); + expect(valueElement.props.children).toBe('50'); + + // Verify hasAttemptedToExceedMax was set to true + const lastDescriptionCall = + mockUseSlippageStepperDescription.mock.calls[ + mockUseSlippageStepperDescription.mock.calls.length - 1 + ]; + expect(lastDescriptionCall[0].hasAttemptedToExceedMax).toBe(true); + }); + + it('rejects input with more decimals than input_max_decimals', () => { + mockSelector.mockReturnValue('1.2'); + + const { getByTestId } = render(); + + // Get the Keypad onChange handler + const keypadOnChange = mockKeypad.mock.calls[0][0].onChange; + + // Try to input value with 3 decimals (exceeds input_max_decimals: 2) + keypadOnChange({ + value: '1.234', + valueAsNumber: 1.234, + pressedKey: '4' as never, + }); + + // Value should remain unchanged (rejected) + const valueElement = getByTestId('keypad-value'); + expect(valueElement.props.children).toBe('1.2'); + }); + + it('accepts input with exact input_max_decimals', () => { + mockSelector.mockReturnValue('1.2'); + + const { getByTestId, rerender } = render(); + + // Get the Keypad onChange handler + const keypadOnChange = mockKeypad.mock.calls[0][0].onChange; + + // Input value with exactly 2 decimals (should be accepted) + keypadOnChange({ + value: '1.25', + valueAsNumber: 1.25, + pressedKey: '5' as never, + }); + + // Re-render to apply state change + rerender(); + + // Value should update + const valueElement = getByTestId('input-stepper-value'); + expect(valueElement.props.children).toBe('1.25'); + }); + + it('handles max_amount with trailing decimal point', () => { + mockSelector.mockReturnValue('100'); + + const { getByTestId, rerender } = render(); + + // Get the Keypad onChange handler + const keypadOnChange = mockKeypad.mock.calls[0][0].onChange; + + // Try to add decimal point to max amount (100.) + keypadOnChange({ + value: '100.', + valueAsNumber: 100, + pressedKey: 'Period' as never, + }); + + // Re-render to apply state change + rerender(); + + // Value should be set to max_amount without decimal point + const valueElement = getByTestId('input-stepper-value'); + expect(valueElement.props.children).toBe('100'); + + // Verify hasAttemptedToExceedMax was set to true + const lastDescriptionCall = + mockUseSlippageStepperDescription.mock.calls[ + mockUseSlippageStepperDescription.mock.calls.length - 1 + ]; + expect(lastDescriptionCall[0].hasAttemptedToExceedMax).toBe(true); + }); + + it('calls hooks with initial input', () => { + mockSelector.mockReturnValue('50'); + + render(); + + // Verify useShouldDisableCustomSlippageConfirm was called + expect(mockUseShouldDisableCustomSlippageConfirm).toHaveBeenCalledWith({ + inputAmount: '50', + slippageConfig: mockSlippageConfig, + }); + + // Verify useSlippageStepperDescription was called with hasAttemptedToExceedMax + expect(mockUseSlippageStepperDescription).toHaveBeenCalledWith({ + inputAmount: '50', + slippageConfig: mockSlippageConfig, + hasAttemptedToExceedMax: false, + }); + }); + + it('resets hasAttemptedToExceedMax on valid keypad input', () => { + mockSelector.mockReturnValue('50'); + + const { rerender } = render(); + + // Get the Keypad onChange handler + const keypadOnChange = mockKeypad.mock.calls[0][0].onChange; + + // Input valid value + keypadOnChange({ + value: '25', + valueAsNumber: 25, + pressedKey: '5' as never, + }); + + // Re-render to apply state change + rerender(); + + // Verify hasAttemptedToExceedMax was reset to false + const lastDescriptionCall = + mockUseSlippageStepperDescription.mock.calls[ + mockUseSlippageStepperDescription.mock.calls.length - 1 + ]; + expect(lastDescriptionCall[0].hasAttemptedToExceedMax).toBe(false); + }); + + it('removes trailing dot when backspace is pressed', () => { + mockSelector.mockReturnValue('5.'); + + const { getByTestId, rerender } = render(); + + // Get the Keypad onChange handler + const keypadOnChange = mockKeypad.mock.calls[0][0].onChange; + + // Simulate backspace press that results in trailing dot + // e.g., user had "5.5", pressed backspace, keypad returns "5." + keypadOnChange({ + value: '5.', + valueAsNumber: 5, + pressedKey: 'Back' as never, + }); + + // Re-render to apply state change + rerender(); + + // Value should have trailing dot removed + const valueElement = getByTestId('input-stepper-value'); + expect(valueElement.props.children).toBe('5'); + }); + + it('removes trailing dot for "0." when backspace is pressed', () => { + mockSelector.mockReturnValue('0.5'); + + const { getByTestId, rerender } = render(); + + // Get the Keypad onChange handler + const keypadOnChange = mockKeypad.mock.calls[0][0].onChange; + + // Simulate backspace press on "0.5" resulting in "0." + keypadOnChange({ + value: '0.', + valueAsNumber: 0, + pressedKey: 'Back' as never, + }); + + // Re-render to apply state change + rerender(); + + // Value should have trailing dot removed + const valueElement = getByTestId('input-stepper-value'); + expect(valueElement.props.children).toBe('0'); + }); + + it('does not remove trailing dot for non-backspace keypresses', () => { + mockSelector.mockReturnValue('5'); + + const { getByTestId, rerender } = render(); + + // Get the Keypad onChange handler + const keypadOnChange = mockKeypad.mock.calls[0][0].onChange; + + // Simulate period key press (not backspace) + keypadOnChange({ + value: '5.', + valueAsNumber: 5, + pressedKey: 'Period' as never, + }); + + // Re-render to apply state change + rerender(); + + // Value should keep the trailing dot since it was a period press + const valueElement = getByTestId('input-stepper-value'); + expect(valueElement.props.children).toBe('5.'); + }); + }); + + describe('initial state', () => { + it('uses currentSlippage from redux when defined', () => { + mockSelector.mockReturnValue('2.5'); + + const { getByTestId } = render(); + + const valueElement = getByTestId('input-stepper-value'); + expect(valueElement.props.children).toBe('2.5'); + }); + + it('defaults to "0" when currentSlippage is undefined', () => { + mockSelector.mockReturnValue(undefined); + + const { getByTestId } = render(); + + const valueElement = getByTestId('input-stepper-value'); + expect(valueElement.props.children).toBe('0'); + }); + }); + + describe('component structure', () => { + it('renders header with correct title', () => { + const { getByText } = render(); + + expect(getByText('Slippage')).toBeTruthy(); + }); + + it('renders InputStepper', () => { + const { getByTestId } = render(); + + expect(getByTestId('input-stepper')).toBeTruthy(); + }); + + it('renders Keypad', () => { + const { getByTestId } = render(); + + expect(getByTestId('keypad')).toBeTruthy(); + }); + + it('renders cancel button', () => { + const { getByText } = render(); + + expect(getByText('Cancel')).toBeTruthy(); + }); + + it('renders confirm button', () => { + const { getByText } = render(); + + expect(getByText('Confirm')).toBeTruthy(); + }); + + it('passes correct props to InputStepper', () => { + mockSelector.mockReturnValue('5'); + const mockDescription = { + type: 'warning', + message: 'Warning', + color: 'text-warning-default', + }; + mockUseSlippageStepperDescription.mockReturnValue( + mockDescription as unknown as ReturnType< + typeof useSlippageStepperDescription + >, + ); + + render(); + + expect(mockInputStepper).toHaveBeenCalledWith( + expect.objectContaining({ + value: '5', + minAmount: mockSlippageConfig.min_amount, + maxAmount: mockSlippageConfig.max_amount, + postValue: '%', + description: mockDescription, + }), + expect.anything(), + ); + }); + + it('passes correct props to Keypad', () => { + mockSelector.mockReturnValue('3.5'); + + render(); + + expect(mockKeypad).toHaveBeenCalledWith( + expect.objectContaining({ + value: '3.5', + currency: 'native', + }), + expect.anything(), + ); + }); + }); + + describe('snapshot tests', () => { + it('matches snapshot for complete modal', () => { + const { toJSON } = render(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('matches snapshot with description shown', () => { + const mockDescription = { + type: 'warning', + message: 'Warning message', + color: 'text-warning-default', + }; + mockUseSlippageStepperDescription.mockReturnValue( + mockDescription as unknown as ReturnType< + typeof useSlippageStepperDescription + >, + ); + + const { toJSON } = render(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('matches snapshot with confirm disabled', () => { + mockUseShouldDisableCustomSlippageConfirm.mockReturnValue(true); + + const { toJSON } = render(); + + expect(toJSON()).toMatchSnapshot(); + }); + }); + + describe('integration with hooks', () => { + it('calls useSlippageConfig with sourceChainId and destChainId params', () => { + mockUseParams.mockReturnValue({ + sourceChainId: 'eip155:1', + destChainId: 'eip155:137', + }); + + render(); + + expect(mockUseSlippageConfig).toHaveBeenCalledWith({ + sourceChainId: 'eip155:1', + destChainId: 'eip155:137', + }); + }); + + it('calls useShouldDisableCustomSlippageConfirm with correct params', () => { + mockSelector.mockReturnValue('5'); + + render(); + + expect(mockUseShouldDisableCustomSlippageConfirm).toHaveBeenCalledWith({ + inputAmount: '5', + slippageConfig: mockSlippageConfig, + }); + }); + + it('calls useSlippageStepperDescription with correct params', () => { + mockSelector.mockReturnValue('10'); + + render(); + + expect(mockUseSlippageStepperDescription).toHaveBeenCalledWith({ + inputAmount: '10', + slippageConfig: mockSlippageConfig, + hasAttemptedToExceedMax: false, + }); + }); + }); + + describe('edge cases', () => { + it('handles zero value', () => { + mockSelector.mockReturnValue('0'); + + const { getByTestId } = render(); + + const valueElement = getByTestId('input-stepper-value'); + expect(valueElement.props.children).toBe('0'); + }); + + it('handles decimal values', () => { + mockSelector.mockReturnValue('1.75'); + + const { getByTestId } = render(); + + const valueElement = getByTestId('input-stepper-value'); + expect(valueElement.props.children).toBe('1.75'); + }); + + it('handles max amount value', () => { + mockSelector.mockReturnValue('100'); + + const { getByTestId } = render(); + + const valueElement = getByTestId('input-stepper-value'); + expect(valueElement.props.children).toBe('100'); + }); + + it('handles multiple increase presses', () => { + mockSelector.mockReturnValue('1'); + + const { getByTestId } = render(); + + const increaseButton = getByTestId('input-stepper-increase'); + + fireEvent.press(increaseButton); // 1.1 + fireEvent.press(increaseButton); // 1.2 + fireEvent.press(increaseButton); // 1.3 + + const valueElement = getByTestId('input-stepper-value'); + expect(valueElement.props.children).toBe('1.3'); + }); + + it('handles multiple decrease presses', () => { + mockSelector.mockReturnValue('1'); + + const { getByTestId } = render(); + + const decreaseButton = getByTestId('input-stepper-decrease'); + + fireEvent.press(decreaseButton); // 0.9 + fireEvent.press(decreaseButton); // 0.8 + fireEvent.press(decreaseButton); // 0.7 + + const valueElement = getByTestId('input-stepper-value'); + expect(valueElement.props.children).toBe('0.7'); + }); + }); + + describe('handleClose functionality', () => { + it('closes modal via header close button', () => { + const { getByLabelText } = render(); + + const closeButton = getByLabelText('Close'); + fireEvent.press(closeButton); + + // Verify it doesn't throw and component handles close + expect(closeButton).toBeTruthy(); + }); + + it('does not dispatch slippage when closing without confirm', () => { + const { getByLabelText } = render(); + + const closeButton = getByLabelText('Close'); + fireEvent.press(closeButton); + + expect(mockDispatch).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.tsx b/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.tsx new file mode 100644 index 00000000000..fa21c40c565 --- /dev/null +++ b/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.tsx @@ -0,0 +1,164 @@ +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 { strings } from '../../../../../../locales/i18n'; +import { View } from 'react-native'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@metamask/design-system-react-native'; +import Keypad, { Keys } from '../../../../Base/Keypad'; +import { InputStepper } from '../InputStepper'; +import { DefaultSlippageModalParams } from './types'; +import { customSlippageModalStyles } from './styles'; +import { useParams } from '../../../../../util/navigation/navUtils'; +import { useSlippageConfig } from '../../hooks/useSlippageConfig'; +import { + selectSlippage, + setSlippage, +} from '../../../../../core/redux/slices/bridge'; +import { useDispatch, useSelector } from 'react-redux'; +import { useSlippageStepperDescription } from '../../hooks/useSlippageStepperDescription'; +import { useShouldDisableCustomSlippageConfirm } from '../../hooks/useShouldDisableCustomSlippageConfirm'; + +export const CustomSlippageModal = () => { + const dispatch = useDispatch(); + const sheetRef = useRef(null); + const { sourceChainId, destChainId } = + useParams(); + const slippageConfig = useSlippageConfig({ sourceChainId, destChainId }); + const currentSlippage = useSelector(selectSlippage); + const [inputAmount, setInputAmount] = useState(currentSlippage ?? '0'); + const [hasAttemptedToExceedMax, setHasAttemptedToExceedMax] = useState(false); + const shouldDisableConfirm = useShouldDisableCustomSlippageConfirm({ + inputAmount, + slippageConfig, + }); + const description = useSlippageStepperDescription({ + inputAmount, + slippageConfig, + hasAttemptedToExceedMax, + }); + + const handleClose = useCallback(() => { + sheetRef.current?.onCloseBottomSheet(); + }, []); + + const handleConfirm = useCallback(() => { + dispatch(setSlippage(inputAmount)); + sheetRef.current?.onCloseBottomSheet(); + }, [dispatch, inputAmount]); + + const handleKeypadChange = useCallback( + (data: { value: string; valueAsNumber: number; pressedKey: Keys }) => { + let newValue = data.value; + setHasAttemptedToExceedMax(false); + + // If user pressed backspace and the result ends with a trailing dot, remove it + if (data.pressedKey === Keys.Back && newValue.endsWith('.')) { + newValue = newValue.slice(0, -1); + } + + const [, decimalPart] = newValue.split('.'); + const valueAsNumber = parseFloat(newValue) || 0; + + // Cap the value to input_max_decimals + if ((decimalPart?.length ?? 0) > slippageConfig.input_max_decimals) { + return; + } + + if (valueAsNumber > slippageConfig.max_amount) { + setHasAttemptedToExceedMax(true); + return; + } + + // Do not render dot when reaching max_amount + if (newValue === slippageConfig.max_amount + '.') { + setInputAmount(String(slippageConfig.max_amount)); + setHasAttemptedToExceedMax(true); + return; + } + + setInputAmount(newValue); + }, + [slippageConfig], + ); + + const handleOnIncreasePress = useCallback(() => { + setHasAttemptedToExceedMax(false); + + setInputAmount((value) => { + const newValue = parseFloat(value) + slippageConfig.input_step; + // Cap the value to max_amount and to input_max_decimals due to JS rounding issues + return newValue >= slippageConfig.max_amount + ? String(slippageConfig.max_amount) + : String( + parseFloat(newValue.toFixed(slippageConfig.input_max_decimals)), + ); + }); + }, [slippageConfig]); + + const handleOnDecreasePress = useCallback(() => { + setHasAttemptedToExceedMax(false); + + setInputAmount((value) => { + const newValue = parseFloat(value) - slippageConfig.input_step; + // Cap the value to min_amount and to input_max_decimals due to JS rounding issues + return newValue <= slippageConfig.min_amount + ? String(slippageConfig.min_amount) + : String( + parseFloat(newValue.toFixed(slippageConfig.input_max_decimals)), + ); + }); + }, [slippageConfig]); + + return ( + + + + + + + + + + + + + + + + + + ); +}; diff --git a/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageButtonGroup.test.tsx b/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageButtonGroup.test.tsx new file mode 100644 index 00000000000..af282b97de7 --- /dev/null +++ b/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageButtonGroup.test.tsx @@ -0,0 +1,368 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { DefaultSlippageButtonGroup } from './DefaultSlippageButtonGroup'; + +describe('DefaultSlippageButtonGroup', () => { + const mockOnPress1 = jest.fn(); + const mockOnPress2 = jest.fn(); + const mockOnPress3 = jest.fn(); + + const defaultOptions = [ + { id: 'auto', label: 'Auto', selected: false, onPress: mockOnPress1 }, + { id: '1', label: '1%', selected: true, onPress: mockOnPress2 }, + { id: '2', label: '2%', selected: false, onPress: mockOnPress3 }, + ]; + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('rendering', () => { + it('renders all provided options', () => { + const { getByText } = render( + , + ); + + expect(getByText('Auto')).toBeTruthy(); + expect(getByText('1%')).toBeTruthy(); + expect(getByText('2%')).toBeTruthy(); + }); + + it('renders correct number of buttons', () => { + const { getAllByRole } = render( + , + ); + + const buttons = getAllByRole('button'); + expect(buttons).toHaveLength(3); + }); + + it('renders empty list when no options provided', () => { + const { queryAllByRole } = render( + , + ); + + const buttons = queryAllByRole('button'); + expect(buttons).toHaveLength(0); + }); + }); + + describe('styling', () => { + it('renders correct styling with one option selected', () => { + const { toJSON } = render( + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders correct styling with no options selected', () => { + const options = [ + { id: 'auto', label: 'Auto', selected: false, onPress: jest.fn() }, + { id: '1', label: '1%', selected: false, onPress: jest.fn() }, + { id: '2', label: '2%', selected: false, onPress: jest.fn() }, + ]; + + const { toJSON } = render( + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders correct styling with first option selected', () => { + const options = [ + { id: 'auto', label: 'Auto', selected: true, onPress: jest.fn() }, + { id: '1', label: '1%', selected: false, onPress: jest.fn() }, + { id: '2', label: '2%', selected: false, onPress: jest.fn() }, + ]; + + const { toJSON } = render( + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders correct styling with last option selected', () => { + const options = [ + { id: 'auto', label: 'Auto', selected: false, onPress: jest.fn() }, + { id: '1', label: '1%', selected: false, onPress: jest.fn() }, + { id: '2', label: '2%', selected: true, onPress: jest.fn() }, + ]; + + const { toJSON } = render( + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders correct styling with multiple options selected', () => { + const options = [ + { id: 'auto', label: 'Auto', selected: true, onPress: jest.fn() }, + { id: '1', label: '1%', selected: true, onPress: jest.fn() }, + { id: '2', label: '2%', selected: false, onPress: jest.fn() }, + ]; + + const { toJSON } = render( + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); + }); + + describe('interaction', () => { + it('calls onPress callback when button is pressed', () => { + const { getByText } = render( + , + ); + + fireEvent.press(getByText('Auto')); + expect(mockOnPress1).toHaveBeenCalledTimes(1); + + fireEvent.press(getByText('1%')); + expect(mockOnPress2).toHaveBeenCalledTimes(1); + + fireEvent.press(getByText('2%')); + expect(mockOnPress3).toHaveBeenCalledTimes(1); + }); + + it('calls correct callback for selected option', () => { + const { getByText } = render( + , + ); + + fireEvent.press(getByText('1%')); // Selected option + expect(mockOnPress2).toHaveBeenCalledTimes(1); + expect(mockOnPress1).not.toHaveBeenCalled(); + expect(mockOnPress3).not.toHaveBeenCalled(); + }); + + it('calls correct callback for unselected option', () => { + const { getByText } = render( + , + ); + + fireEvent.press(getByText('Auto')); // Unselected option + expect(mockOnPress1).toHaveBeenCalledTimes(1); + expect(mockOnPress2).not.toHaveBeenCalled(); + expect(mockOnPress3).not.toHaveBeenCalled(); + }); + + it('handles multiple presses on same button', () => { + const { getByText } = render( + , + ); + + const button = getByText('Auto'); + fireEvent.press(button); + fireEvent.press(button); + fireEvent.press(button); + + expect(mockOnPress1).toHaveBeenCalledTimes(3); + }); + }); + + describe('edge cases', () => { + it('handles single option', () => { + const options = [ + { id: 'auto', label: 'Auto', selected: true, onPress: jest.fn() }, + ]; + + const { toJSON, getByText } = render( + , + ); + + expect(getByText('Auto')).toBeTruthy(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('handles many options', () => { + const options = [ + { id: '1', label: '1%', selected: false, onPress: jest.fn() }, + { id: '2', label: '2%', selected: false, onPress: jest.fn() }, + { id: '3', label: '3%', selected: true, onPress: jest.fn() }, + { id: '4', label: '4%', selected: false, onPress: jest.fn() }, + { id: '5', label: '5%', selected: false, onPress: jest.fn() }, + { id: 'custom', label: 'Custom', selected: false, onPress: jest.fn() }, + ]; + + const { toJSON, getAllByRole } = render( + , + ); + + const buttons = getAllByRole('button'); + expect(buttons).toHaveLength(6); + expect(toJSON()).toMatchSnapshot(); + }); + + it('handles long labels', () => { + const options = [ + { + id: '1', + label: 'Very Long Custom Label', + selected: false, + onPress: jest.fn(), + }, + { + id: '2', + label: 'Another Super Long Label Here', + selected: true, + onPress: jest.fn(), + }, + ]; + + const { toJSON } = render( + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('handles options without selected property', () => { + const options = [ + { id: 'auto', label: 'Auto', onPress: jest.fn() }, + { id: '1', label: '1%', onPress: jest.fn() }, + ]; + + const { toJSON } = render( + , + ); + + // Should default to unselected (secondary variant) + expect(toJSON()).toMatchSnapshot(); + }); + + it('handles special characters in labels', () => { + const options = [ + { id: '1', label: '< 0.5%', selected: false, onPress: jest.fn() }, + { id: '2', label: '≥ 1%', selected: true, onPress: jest.fn() }, + ]; + + const { getByText } = render( + , + ); + + expect(getByText('< 0.5%')).toBeTruthy(); + expect(getByText('≥ 1%')).toBeTruthy(); + }); + }); + + describe('button variants', () => { + it('uses Primary variant for selected button', () => { + const options = [ + { id: '1', label: '1%', selected: true, onPress: jest.fn() }, + ]; + + const { toJSON } = render( + , + ); + + expect(toJSON()).toMatchSnapshot('primary variant'); + }); + + it('uses Secondary variant for unselected button', () => { + const options = [ + { id: '1', label: '1%', selected: false, onPress: jest.fn() }, + ]; + + const { toJSON } = render( + , + ); + + expect(toJSON()).toMatchSnapshot('secondary variant'); + }); + + it('handles mixed selected states correctly', () => { + const options = [ + { id: '1', label: '1%', selected: false, onPress: jest.fn() }, + { id: '2', label: '2%', selected: true, onPress: jest.fn() }, + { id: '3', label: '3%', selected: false, onPress: jest.fn() }, + ]; + + const { toJSON } = render( + , + ); + + expect(toJSON()).toMatchSnapshot('mixed variants'); + }); + }); + + describe('unique keys', () => { + it('uses label as key for each option', () => { + const options = [ + { id: 'auto', label: 'Auto', selected: true, onPress: jest.fn() }, + { id: '1', label: '1%', selected: false, onPress: jest.fn() }, + ]; + + // Should not throw duplicate key warning + const { toJSON } = render( + , + ); + + expect(toJSON()).toBeTruthy(); + }); + + it('handles duplicate labels gracefully', () => { + const options = [ + { id: '1', label: 'Auto', selected: true, onPress: jest.fn() }, + { id: '2', label: 'Auto', selected: false, onPress: jest.fn() }, + ]; + + const { getAllByText } = render( + , + ); + + const buttons = getAllByText('Auto'); + expect(buttons).toHaveLength(2); + }); + }); + + describe('complete component snapshots', () => { + it('matches snapshot for typical slippage options', () => { + const options = [ + { id: 'auto', label: 'Auto', selected: false, onPress: jest.fn() }, + { id: '0.5', label: '0.5%', selected: false, onPress: jest.fn() }, + { id: '2', label: '2%', selected: true, onPress: jest.fn() }, + { id: '3', label: '3%', selected: false, onPress: jest.fn() }, + { id: 'custom', label: 'Custom', selected: false, onPress: jest.fn() }, + ]; + + const { toJSON } = render( + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('matches snapshot with auto selected', () => { + const options = [ + { id: 'auto', label: 'Auto', selected: true, onPress: jest.fn() }, + { id: '1', label: '1%', selected: false, onPress: jest.fn() }, + { id: '2', label: '2%', selected: false, onPress: jest.fn() }, + ]; + + const { toJSON } = render( + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('matches snapshot with custom selected', () => { + const options = [ + { id: 'auto', label: 'Auto', selected: false, onPress: jest.fn() }, + { id: '1', label: '1%', selected: false, onPress: jest.fn() }, + { id: 'custom', label: 'Custom', selected: true, onPress: jest.fn() }, + ]; + + const { toJSON } = render( + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); + }); +}); diff --git a/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageButtonGroup.tsx b/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageButtonGroup.tsx new file mode 100644 index 00000000000..dc5b89a1faa --- /dev/null +++ b/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageButtonGroup.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { View } from 'react-native'; +import { defaultSlippageButtonGroupStyles as styles } from './styles'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@metamask/design-system-react-native'; + +interface DefaultSlippageOption { + id: string; + label: string; + selected?: boolean; + onPress: () => void; +} + +interface Props { + options: DefaultSlippageOption[]; +} + +export const DefaultSlippageButtonGroup = ({ options }: Props) => ( + + {options.map((option) => ( + + + + ))} + +); diff --git a/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.test.tsx b/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.test.tsx new file mode 100644 index 00000000000..481a7e7e66e --- /dev/null +++ b/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.test.tsx @@ -0,0 +1,669 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { DefaultSlippageModal } from './DefaultSlippageModal'; +import Routes from '../../../../../constants/navigation/Routes'; + +// Mock BottomSheet +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheet', + () => { + const ReactModule = jest.requireActual('react'); + const ReactNative = jest.requireActual('react-native'); + const { View } = ReactNative; + + return { + __esModule: true, + default: ReactModule.forwardRef( + (props: { children: unknown }, _ref: unknown) => ( + {props.children as React.ReactNode} + ), + ), + }; + }, +); + +// Mock HeaderCenter +jest.mock( + '../../../../../component-library/components-temp/HeaderCenter', + () => { + const ReactNative = jest.requireActual('react-native'); + const { View, Text, TouchableOpacity } = ReactNative; + + return { + __esModule: true, + default: (props: { title: string; onClose: () => void }) => ( + + {props.title} + + Close + + + ), + }; + }, +); + +// Mock dependencies +jest.mock('./DefaultSlippageButtonGroup', () => ({ + DefaultSlippageButtonGroup: jest.fn(({ options }) => { + const ReactNative = jest.requireActual('react-native'); + const { View, Text, TouchableOpacity } = ReactNative; + return ( + + {options.map( + (option: { id: string; label: string; onPress: () => void }) => ( + + {option.label} + + ), + )} + + ); + }), +})); + +jest.mock('../../hooks/useGetSlippageOptions', () => ({ + useGetSlippageOptions: jest.fn(), +})); + +jest.mock('../../hooks/useSlippageConfig', () => ({ + useSlippageConfig: jest.fn(), +})); + +jest.mock('../../../../../util/navigation/navUtils', () => ({ + useParams: jest.fn(), +})); + +// Mock Redux +const mockDispatch = jest.fn(); +const mockSelector = jest.fn(); + +jest.mock('react-redux', () => ({ + useDispatch: () => mockDispatch, + useSelector: (selector: (state: unknown) => unknown) => + mockSelector(selector), +})); + +// Mock navigation +const mockGoBack = jest.fn(); +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ + goBack: mockGoBack, + navigate: mockNavigate, + }), +})); + +// Mock i18n +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => { + const translations: Record = { + 'bridge.slippage': 'Slippage', + 'bridge.default_slippage_description': 'Set your slippage tolerance', + 'bridge.submit': 'Submit', + }; + return translations[key] || key; + }), +})); + +import { useGetSlippageOptions } from '../../hooks/useGetSlippageOptions'; +import { useSlippageConfig } from '../../hooks/useSlippageConfig'; +import { useParams } from '../../../../../util/navigation/navUtils'; +import { AUTO_SLIPPAGE_VALUE } from './constants'; + +const mockUseGetSlippageOptions = useGetSlippageOptions as jest.MockedFunction< + typeof useGetSlippageOptions +>; +const mockUseSlippageConfig = useSlippageConfig as jest.MockedFunction< + typeof useSlippageConfig +>; +const mockUseParams = useParams as jest.MockedFunction; + +describe('DefaultSlippageModal', () => { + const mockSlippageConfig = { + input_step: 0.1, + max_amount: 100, + min_amount: 0, + input_max_decimals: 2, + lower_allowed_slippage_threshold: null, + lower_suggested_slippage_threshold: null, + upper_suggested_slippage_threshold: null, + upper_allowed_slippage_threshold: null, + default_slippage_options: ['auto', '0.5', '2', '3'], + has_custom_slippage_option: true, + }; + + const mockSlippageOptions = [ + { id: 'auto', label: 'Auto', selected: true, onPress: jest.fn() }, + { id: '0.5', label: '0.5%', selected: false, onPress: jest.fn() }, + { id: '2', label: '2%', selected: false, onPress: jest.fn() }, + { id: '3', label: '3%', selected: false, onPress: jest.fn() }, + { id: 'custom', label: 'Custom', selected: false, onPress: jest.fn() }, + ]; + + beforeEach(() => { + mockUseSlippageConfig.mockReturnValue(mockSlippageConfig); + mockUseGetSlippageOptions.mockReturnValue(mockSlippageOptions); + mockUseParams.mockReturnValue({ + sourceChainId: '0x1', + destChainId: undefined, + }); + mockSelector.mockReturnValue(undefined); // Default: no slippage set + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('initial state', () => { + it('uses auto as default when slippage is not defined in redux', () => { + mockSelector.mockReturnValue(undefined); + + render(); + + expect(mockUseGetSlippageOptions).toHaveBeenCalledWith( + expect.objectContaining({ + slippage: AUTO_SLIPPAGE_VALUE, + }), + ); + }); + + it('uses redux slippage value when defined', () => { + mockSelector.mockReturnValue('2'); + + render(); + + expect(mockUseGetSlippageOptions).toHaveBeenCalledWith( + expect.objectContaining({ + slippage: '2', + }), + ); + }); + + it('passes sourceChainId and destChainId params to useSlippageConfig', () => { + mockUseParams.mockReturnValue({ + sourceChainId: 'eip155:1', + destChainId: 'eip155:137', + }); + + render(); + + expect(mockUseSlippageConfig).toHaveBeenCalledWith({ + sourceChainId: 'eip155:1', + destChainId: 'eip155:137', + }); + }); + + it('uses slippage config options', () => { + render(); + + expect(mockUseGetSlippageOptions).toHaveBeenCalledWith( + expect.objectContaining({ + slippageOptions: mockSlippageConfig.default_slippage_options, + allowCustomSlippage: mockSlippageConfig.has_custom_slippage_option, + }), + ); + }); + }); + + describe('handleClose', () => { + it('closes bottom sheet when close is called', () => { + const { getByLabelText } = render(); + + const closeButton = getByLabelText('Close'); + fireEvent.press(closeButton); + + // Bottom sheet close is handled internally by ref + // We verify the component renders without errors + expect(closeButton).toBeTruthy(); + }); + }); + + describe('handleCustomOptionPress', () => { + it('navigates to custom slippage modal when custom option is pressed', () => { + render(); + + // Get the actual handler passed to useGetSlippageOptions + const call = mockUseGetSlippageOptions.mock.calls[0][0]; + const handleCustomOptionPress = call.onCustomOptionPress; + + // Call the real handler + handleCustomOptionPress?.(); + + expect(mockGoBack).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, { + screen: Routes.BRIDGE.MODALS.CUSTOM_SLIPPAGE_MODAL, + params: { + sourceChainId: '0x1', + destChainId: undefined, + }, + }); + }); + + it('calls goBack before navigating to custom modal', () => { + render(); + + // Get the actual handler + const call = mockUseGetSlippageOptions.mock.calls[0][0]; + const handleCustomOptionPress = call.onCustomOptionPress; + + handleCustomOptionPress?.(); + + const callOrder = [ + mockGoBack.mock.invocationCallOrder[0], + mockNavigate.mock.invocationCallOrder[0], + ]; + expect(callOrder[0]).toBeLessThan(callOrder[1]); + }); + }); + + describe('handleDefaultOptionPress', () => { + it('updates selected slippage when default option is pressed', () => { + const { rerender } = render(); + + // Get the actual handler passed to useGetSlippageOptions + const call = mockUseGetSlippageOptions.mock.calls[0][0]; + const handleDefaultOptionPress = call.onDefaultOptionPress; + + // Call the handler with a value - it should return a function + const pressHandler = handleDefaultOptionPress('2'); + pressHandler(); + + // Re-render and verify hook was called with new value + rerender(); + + // Verify useGetSlippageOptions was called again with updated slippage + expect(mockUseGetSlippageOptions).toHaveBeenCalledWith( + expect.objectContaining({ + slippage: '2', + }), + ); + }); + }); + + describe('handleSubmit', () => { + it('dispatches undefined when auto is selected', () => { + mockSelector.mockReturnValue(undefined); + + const { getByText } = render(); + + const submitButton = getByText('Submit'); + fireEvent.press(submitButton); + + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: expect.stringContaining('setSlippage'), + payload: undefined, + }), + ); + }); + + it('dispatches slippage value as string when numeric value selected', () => { + mockSelector.mockReturnValue('2'); + + const { getByText } = render(); + + const submitButton = getByText('Submit'); + fireEvent.press(submitButton); + + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + payload: '2', + }), + ); + }); + + it('dispatches updated slippage after user changes selection', () => { + mockSelector.mockReturnValue('1'); + + const { getByText, rerender } = render(); + + // Get the handler and change selection to '3' + const call = mockUseGetSlippageOptions.mock.calls[0][0]; + const handleDefaultOptionPress = call.onDefaultOptionPress; + const pressHandler = handleDefaultOptionPress('3'); + pressHandler(); + + // Re-render to apply state change + rerender(); + + const submitButton = getByText('Submit'); + fireEvent.press(submitButton); + + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + payload: '3', + }), + ); + }); + + it('dispatches undefined when selectedSlippage is undefined', () => { + mockSelector.mockReturnValue(undefined); + + const { getByText } = render(); + + const submitButton = getByText('Submit'); + fireEvent.press(submitButton); + + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + payload: undefined, + }), + ); + }); + + it('converts numeric slippage to string before dispatching', () => { + mockSelector.mockReturnValue('1.5'); + + const { getByText } = render(); + + const submitButton = getByText('Submit'); + fireEvent.press(submitButton); + + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + payload: '1.5', + }), + ); + }); + + it('closes bottom sheet after dispatching', () => { + const { getByText } = render(); + + const submitButton = getByText('Submit'); + fireEvent.press(submitButton); + + // Verify dispatch was called and component doesn't error + expect(mockDispatch).toHaveBeenCalled(); + }); + }); + + describe('component structure', () => { + it('renders header with correct title', () => { + const { getByText } = render(); + + expect(getByText('Slippage')).toBeTruthy(); + }); + + it('renders description text', () => { + const { getByText } = render(); + + expect(getByText('Set your slippage tolerance')).toBeTruthy(); + }); + + it('renders DefaultSlippageButtonGroup with options', () => { + const { getByTestId } = render(); + + expect(getByTestId('default-slippage-button-group')).toBeTruthy(); + }); + + it('renders submit button', () => { + const { getByText } = render(); + + expect(getByText('Submit')).toBeTruthy(); + }); + + it('passes correct props to useGetSlippageOptions', () => { + render(); + + expect(mockUseGetSlippageOptions).toHaveBeenCalledWith({ + slippageOptions: mockSlippageConfig.default_slippage_options, + allowCustomSlippage: mockSlippageConfig.has_custom_slippage_option, + slippage: AUTO_SLIPPAGE_VALUE, + onDefaultOptionPress: expect.any(Function), + onCustomOptionPress: expect.any(Function), + }); + }); + }); + + describe('snapshot tests', () => { + it('matches snapshot for complete modal', () => { + const { toJSON } = render(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('matches snapshot with auto selected', () => { + mockSelector.mockReturnValue(undefined); + + const { toJSON } = render(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('matches snapshot with numeric slippage selected', () => { + mockSelector.mockReturnValue('2'); + + const { toJSON } = render(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('matches snapshot for header', () => { + const { getByText, toJSON } = render(); + + expect(getByText('Slippage')).toBeTruthy(); + expect(toJSON()).toMatchSnapshot('header style'); + }); + + it('matches snapshot for description', () => { + const { getByText, toJSON } = render(); + + expect(getByText('Set your slippage tolerance')).toBeTruthy(); + expect(toJSON()).toMatchSnapshot('description style'); + }); + + it('matches snapshot for submit button', () => { + const { getByText, toJSON } = render(); + + expect(getByText('Submit')).toBeTruthy(); + expect(toJSON()).toMatchSnapshot('submit button style'); + }); + }); + + describe('integration with hooks', () => { + it('updates options when config changes', () => { + const newConfig = { + ...mockSlippageConfig, + default_slippage_options: ['auto', '1', '2'], + has_custom_slippage_option: false, + }; + + mockUseSlippageConfig.mockReturnValue(newConfig); + + render(); + + expect(mockUseGetSlippageOptions).toHaveBeenCalledWith( + expect.objectContaining({ + slippageOptions: ['auto', '1', '2'], + allowCustomSlippage: false, + }), + ); + }); + + it('handles Solana chain params from navigation', () => { + mockUseParams.mockReturnValue({ + sourceChainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + destChainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + }); + + render(); + + expect(mockUseSlippageConfig).toHaveBeenCalledWith({ + sourceChainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + destChainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + }); + }); + + it('handles undefined chain params', () => { + mockUseParams.mockReturnValue({ + sourceChainId: undefined, + destChainId: undefined, + }); + + render(); + + expect(mockUseSlippageConfig).toHaveBeenCalledWith({ + sourceChainId: undefined, + destChainId: undefined, + }); + }); + + it('handles sourceChainId only (no destChainId)', () => { + mockUseParams.mockReturnValue({ + sourceChainId: '0x1', + destChainId: undefined, + }); + + render(); + + expect(mockUseSlippageConfig).toHaveBeenCalledWith({ + sourceChainId: '0x1', + destChainId: undefined, + }); + }); + }); + + describe('edge cases', () => { + it('handles empty slippage options', () => { + mockUseGetSlippageOptions.mockReturnValue([]); + + const { toJSON } = render(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('handles zero slippage value', () => { + mockSelector.mockReturnValue('0'); + + const { getByText } = render(); + + const submitButton = getByText('Submit'); + fireEvent.press(submitButton); + + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + payload: '0', + }), + ); + }); + + it('handles decimal slippage value', () => { + mockSelector.mockReturnValue('1.5'); + + render(); + + expect(mockUseGetSlippageOptions).toHaveBeenCalledWith( + expect.objectContaining({ + slippage: '1.5', + }), + ); + }); + + it('handles very large slippage value', () => { + mockSelector.mockReturnValue('99.99'); + + const { getByText } = render(); + + const submitButton = getByText('Submit'); + fireEvent.press(submitButton); + + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + payload: '99.99', + }), + ); + }); + }); + + describe('state management', () => { + it('maintains local state separate from redux', () => { + mockSelector.mockReturnValue('2'); + + render(); + + // Initial state should match redux + expect(mockUseGetSlippageOptions).toHaveBeenCalledWith( + expect.objectContaining({ + slippage: '2', + }), + ); + + // Local state can change without affecting redux until submit + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('only dispatches to redux on submit', () => { + const { getByText } = render(); + + // Change local state (simulated by pressing option) + // Verify dispatch not called yet + expect(mockDispatch).not.toHaveBeenCalled(); + + // Submit + const submitButton = getByText('Submit'); + fireEvent.press(submitButton); + + // Now dispatch should be called + expect(mockDispatch).toHaveBeenCalledTimes(1); + }); + }); + + describe('callback handlers', () => { + it('handleDefaultOptionPress returns a function', () => { + render(); + + const call = mockUseGetSlippageOptions.mock.calls[0][0]; + const handler = call.onDefaultOptionPress; + + // Should return a function + const pressHandler = handler('2'); + expect(typeof pressHandler).toBe('function'); + }); + + it('handleCustomOptionPress is passed to useGetSlippageOptions', () => { + render(); + + const call = mockUseGetSlippageOptions.mock.calls[0][0]; + expect(call.onCustomOptionPress).toBeDefined(); + expect(typeof call.onCustomOptionPress).toBe('function'); + }); + }); + + describe('auto slippage behavior', () => { + it('dispatches undefined for auto slippage on submit', () => { + mockSelector.mockReturnValue(undefined); + + const { getByText } = render(); + + const submitButton = getByText('Submit'); + fireEvent.press(submitButton); + + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + payload: undefined, + }), + ); + }); + + it('treats AUTO_SLIPPAGE_VALUE constant as auto', () => { + mockSelector.mockReturnValue(AUTO_SLIPPAGE_VALUE); + + const { getByText } = render(); + + const submitButton = getByText('Submit'); + fireEvent.press(submitButton); + + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + payload: undefined, + }), + ); + }); + }); +}); diff --git a/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.tsx b/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.tsx new file mode 100644 index 00000000000..7f7e727f3e4 --- /dev/null +++ b/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.tsx @@ -0,0 +1,104 @@ +import React, { useCallback, useRef, useState } from 'react'; +import HeaderCenter from '../../../../../component-library/components-temp/HeaderCenter'; +import BottomSheet, { + BottomSheetRef, +} from '../../../../../component-library/components/BottomSheets/BottomSheet'; +import { strings } from '../../../../../../locales/i18n'; +import { View } from 'react-native'; +import { + Button, + ButtonSize, + ButtonVariant, + Text, +} from '@metamask/design-system-react-native'; +import { DefaultSlippageButtonGroup } from './DefaultSlippageButtonGroup'; +import { defaultSlippageModalStyles as styles } from './styles'; +import { useNavigation } from '@react-navigation/native'; +import Routes from '../../../../../constants/navigation/Routes'; +import { useDispatch, useSelector } from 'react-redux'; +import { + selectSlippage, + setSlippage, +} from '../../../../../core/redux/slices/bridge'; +import { useGetSlippageOptions } from '../../hooks/useGetSlippageOptions'; +import { AUTO_SLIPPAGE_VALUE } from './constants'; +import { DefaultSlippageModalParams } from './types'; +import { useParams } from '../../../../../util/navigation/navUtils'; +import { useSlippageConfig } from '../../hooks/useSlippageConfig'; +import { SlippageType } from '../../types'; + +export const DefaultSlippageModal = () => { + const navigation = useNavigation(); + const dispatch = useDispatch(); + const sheetRef = useRef(null); + const slippage = useSelector(selectSlippage); + const [selectedSlippage, setSelectedSlippage] = useState( + slippage ?? AUTO_SLIPPAGE_VALUE, + ); + const { sourceChainId, destChainId } = + useParams(); + const slippageConfig = useSlippageConfig({ sourceChainId, destChainId }); + + const handleClose = useCallback(() => { + sheetRef.current?.onCloseBottomSheet(); + }, []); + + const handleCustomOptionPress = useCallback(() => { + navigation.goBack(); + navigation.navigate(Routes.BRIDGE.MODALS.ROOT, { + screen: Routes.BRIDGE.MODALS.CUSTOM_SLIPPAGE_MODAL, + params: { sourceChainId, destChainId }, + }); + }, [navigation, sourceChainId, destChainId]); + + const handleSubmit = useCallback(() => { + dispatch( + setSlippage( + selectedSlippage === undefined || + selectedSlippage === AUTO_SLIPPAGE_VALUE + ? undefined + : String(selectedSlippage), + ), + ); + sheetRef.current?.onCloseBottomSheet(); + }, [selectedSlippage, dispatch]); + + const handleDefaultOptionPress = useCallback( + (value: SlippageType) => () => { + setSelectedSlippage(value); + }, + [], + ); + + const slippageOptions = useGetSlippageOptions({ + slippageOptions: slippageConfig.default_slippage_options, + allowCustomSlippage: slippageConfig.has_custom_slippage_option, + slippage: selectedSlippage, + onDefaultOptionPress: handleDefaultOptionPress, + onCustomOptionPress: handleCustomOptionPress, + }); + + return ( + + + + + {strings('bridge.default_slippage_description')} + + + + + + + + + + ); +}; diff --git a/app/components/UI/Bridge/components/SlippageModal/SlippageModal.styles.ts b/app/components/UI/Bridge/components/SlippageModal/SlippageModal.styles.ts deleted file mode 100644 index e93eba6722f..00000000000 --- a/app/components/UI/Bridge/components/SlippageModal/SlippageModal.styles.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Platform, StyleSheet } from 'react-native'; -import { Theme } from '../../../../../util/theme/models'; - -const createStyles = (_params: { theme: Theme }) => - StyleSheet.create({ - container: { - paddingHorizontal: 16, - }, - optionsContainer: { - marginTop: 16, - }, - segmentedControl: { - gap: 8, - }, - footer: { - paddingHorizontal: 0, - paddingTop: 24, - paddingBottom: Platform.OS === 'android' ? 0 : 16, - }, - }); - -export default createStyles; diff --git a/app/components/UI/Bridge/components/SlippageModal/SlippageModal.test.tsx b/app/components/UI/Bridge/components/SlippageModal/SlippageModal.test.tsx deleted file mode 100644 index 16cda541945..00000000000 --- a/app/components/UI/Bridge/components/SlippageModal/SlippageModal.test.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { initialState } from '../../_mocks_/initialState'; -import React from 'react'; -import { fireEvent } from '@testing-library/react-native'; -import { SafeAreaProvider, Metrics } from 'react-native-safe-area-context'; - -import renderWithProvider from '../../../../../util/test/renderWithProvider'; -import { strings } from '../../../../../../locales/i18n'; -import SlippageModal from './index'; -import { setSlippage } from '../../../../../core/redux/slices/bridge'; - -const mockNavigate = jest.fn(); -const mockGoBack = jest.fn(); -const mockDispatch = jest.fn(); - -jest.mock('@react-navigation/native', () => { - const actualReactNavigation = jest.requireActual('@react-navigation/native'); - return { - ...actualReactNavigation, - useNavigation: jest.fn(() => ({ - navigate: mockNavigate, - goBack: mockGoBack, - })), - }; -}); - -jest.mock('react-redux', () => { - const actualReactRedux = jest.requireActual('react-redux'); - return { - ...actualReactRedux, - useDispatch: () => mockDispatch, - }; -}); - -const initialMetrics: Metrics = { - frame: { x: 0, y: 0, width: 320, height: 640 }, - insets: { top: 0, left: 0, right: 0, bottom: 0 }, -}; - -const renderSlippageModal = () => - renderWithProvider( - - - , - { - state: initialState, - }, - ); - -describe('SlippageModal', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('renders all UI elements with the proper slippage options and apply button', () => { - const { toJSON, getByText } = renderSlippageModal(); - - expect(getByText(strings('bridge.slippage'))).toBeDefined(); - expect(getByText(strings('bridge.slippage_info'))).toBeDefined(); - expect(getByText('0.5%')).toBeDefined(); - expect(getByText('1%')).toBeDefined(); - expect(getByText('2%')).toBeDefined(); - expect(getByText('5%')).toBeDefined(); - expect(getByText(strings('bridge.apply'))).toBeDefined(); - expect(toJSON()).toMatchSnapshot(); - }); - - it('updates slippage value when segment is selected and dispatches action when applied', () => { - const { getByText, getByTestId } = renderSlippageModal(); - - // Click on the 3% option - const option2Percent = getByTestId('slippage-option-2'); - fireEvent.press(option2Percent); - - // Click on the apply button - const applyButton = getByText(strings('bridge.apply')); - fireEvent.press(applyButton); - - // Check if the action was dispatched with the correct value - expect(mockDispatch).toHaveBeenCalledWith(setSlippage('2')); - - // Check that navigation.goBack was called - expect(mockGoBack).toHaveBeenCalled(); - }); -}); diff --git a/app/components/UI/Bridge/components/SlippageModal/SlippageModal.types.ts b/app/components/UI/Bridge/components/SlippageModal/SlippageModal.types.ts deleted file mode 100644 index ef259a08bcf..00000000000 --- a/app/components/UI/Bridge/components/SlippageModal/SlippageModal.types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface SlippageOption { - label: string; - value: string; -} diff --git a/app/components/UI/Bridge/components/SlippageModal/__snapshots__/CustomSlippageModal.test.tsx.snap b/app/components/UI/Bridge/components/SlippageModal/__snapshots__/CustomSlippageModal.test.tsx.snap new file mode 100644 index 00000000000..a0cc4cab51b --- /dev/null +++ b/app/components/UI/Bridge/components/SlippageModal/__snapshots__/CustomSlippageModal.test.tsx.snap @@ -0,0 +1,2229 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CustomSlippageModal confirm button is disabled when shouldDisableConfirm is true: confirm button disabled 1`] = ` + + + + Slippage + + + + Close + + + + + + + + - + + + + 0 + + + + + + + + + + + + + 0 + + + + 5 + + + + + + + + + + + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + Confirm + + + + + + + +`; + +exports[`CustomSlippageModal confirm button is enabled when shouldDisableConfirm is false: confirm button enabled 1`] = ` + + + + Slippage + + + + Close + + + + + + + + - + + + + 0 + + + + + + + + + + + + + 0 + + + + 5 + + + + + + + + + + + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + Confirm + + + + + + + +`; + +exports[`CustomSlippageModal snapshot tests matches snapshot for complete modal 1`] = ` + + + + Slippage + + + + Close + + + + + + + + - + + + + 0 + + + + + + + + + + + + + 0 + + + + 5 + + + + + + + + + + + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + Confirm + + + + + + + +`; + +exports[`CustomSlippageModal snapshot tests matches snapshot with confirm disabled 1`] = ` + + + + Slippage + + + + Close + + + + + + + + - + + + + 0 + + + + + + + + + + + + + 0 + + + + 5 + + + + + + + + + + + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + Confirm + + + + + + + +`; + +exports[`CustomSlippageModal snapshot tests matches snapshot with description shown 1`] = ` + + + + Slippage + + + + Close + + + + + + + + - + + + + 0 + + + + + + + + + + + + + + 0 + + + + 5 + + + + + + + + + + + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + Confirm + + + + + + + +`; diff --git a/app/components/UI/Bridge/components/SlippageModal/__snapshots__/DefaultSlippageButtonGroup.test.tsx.snap b/app/components/UI/Bridge/components/SlippageModal/__snapshots__/DefaultSlippageButtonGroup.test.tsx.snap new file mode 100644 index 00000000000..4acaf17b586 --- /dev/null +++ b/app/components/UI/Bridge/components/SlippageModal/__snapshots__/DefaultSlippageButtonGroup.test.tsx.snap @@ -0,0 +1,7424 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DefaultSlippageButtonGroup button variants handles mixed selected states correctly: mixed variants 1`] = ` + + + + + + + + + + + + + + 1% + + + + + + + + + + + + + + + + + + 2% + + + + + + + + + + + + + + + + + + 3% + + + + + + +`; + +exports[`DefaultSlippageButtonGroup button variants uses Primary variant for selected button: primary variant 1`] = ` + + + + + + + + + + + + + + 1% + + + + + + +`; + +exports[`DefaultSlippageButtonGroup button variants uses Secondary variant for unselected button: secondary variant 1`] = ` + + + + + + + + + + + + + + 1% + + + + + + +`; + +exports[`DefaultSlippageButtonGroup complete component snapshots matches snapshot for typical slippage options 1`] = ` + + + + + + + + + + + + + + Auto + + + + + + + + + + + + + + + + + + 0.5% + + + + + + + + + + + + + + + + + + 2% + + + + + + + + + + + + + + + + + + 3% + + + + + + + + + + + + + + + + + + Custom + + + + + + +`; + +exports[`DefaultSlippageButtonGroup complete component snapshots matches snapshot with auto selected 1`] = ` + + + + + + + + + + + + + + Auto + + + + + + + + + + + + + + + + + + 1% + + + + + + + + + + + + + + + + + + 2% + + + + + + +`; + +exports[`DefaultSlippageButtonGroup complete component snapshots matches snapshot with custom selected 1`] = ` + + + + + + + + + + + + + + Auto + + + + + + + + + + + + + + + + + + 1% + + + + + + + + + + + + + + + + + + Custom + + + + + + +`; + +exports[`DefaultSlippageButtonGroup edge cases handles long labels 1`] = ` + + + + + + + + + + + + + + Very Long Custom Label + + + + + + + + + + + + + + + + + + Another Super Long Label Here + + + + + + +`; + +exports[`DefaultSlippageButtonGroup edge cases handles many options 1`] = ` + + + + + + + + + + + + + + 1% + + + + + + + + + + + + + + + + + + 2% + + + + + + + + + + + + + + + + + + 3% + + + + + + + + + + + + + + + + + + 4% + + + + + + + + + + + + + + + + + + 5% + + + + + + + + + + + + + + + + + + Custom + + + + + + +`; + +exports[`DefaultSlippageButtonGroup edge cases handles options without selected property 1`] = ` + + + + + + + + + + + + + + Auto + + + + + + + + + + + + + + + + + + 1% + + + + + + +`; + +exports[`DefaultSlippageButtonGroup edge cases handles single option 1`] = ` + + + + + + + + + + + + + + Auto + + + + + + +`; + +exports[`DefaultSlippageButtonGroup styling renders correct styling with first option selected 1`] = ` + + + + + + + + + + + + + + Auto + + + + + + + + + + + + + + + + + + 1% + + + + + + + + + + + + + + + + + + 2% + + + + + + +`; + +exports[`DefaultSlippageButtonGroup styling renders correct styling with last option selected 1`] = ` + + + + + + + + + + + + + + Auto + + + + + + + + + + + + + + + + + + 1% + + + + + + + + + + + + + + + + + + 2% + + + + + + +`; + +exports[`DefaultSlippageButtonGroup styling renders correct styling with multiple options selected 1`] = ` + + + + + + + + + + + + + + Auto + + + + + + + + + + + + + + + + + + 1% + + + + + + + + + + + + + + + + + + 2% + + + + + + +`; + +exports[`DefaultSlippageButtonGroup styling renders correct styling with no options selected 1`] = ` + + + + + + + + + + + + + + Auto + + + + + + + + + + + + + + + + + + 1% + + + + + + + + + + + + + + + + + + 2% + + + + + + +`; + +exports[`DefaultSlippageButtonGroup styling renders correct styling with one option selected 1`] = ` + + + + + + + + + + + + + + Auto + + + + + + + + + + + + + + + + + + 1% + + + + + + + + + + + + + + + + + + 2% + + + + + + +`; diff --git a/app/components/UI/Bridge/components/SlippageModal/__snapshots__/DefaultSlippageModal.test.tsx.snap b/app/components/UI/Bridge/components/SlippageModal/__snapshots__/DefaultSlippageModal.test.tsx.snap new file mode 100644 index 00000000000..078061728fc --- /dev/null +++ b/app/components/UI/Bridge/components/SlippageModal/__snapshots__/DefaultSlippageModal.test.tsx.snap @@ -0,0 +1,1871 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DefaultSlippageModal edge cases handles empty slippage options 1`] = ` + + + + Slippage + + + + Close + + + + + + Set your slippage tolerance + + + + + + + + + + + + + + + + + + Submit + + + + + + +`; + +exports[`DefaultSlippageModal snapshot tests matches snapshot for complete modal 1`] = ` + + + + Slippage + + + + Close + + + + + + Set your slippage tolerance + + + + + + + Auto + + + + + 0.5% + + + + + 2% + + + + + 3% + + + + + Custom + + + + + + + + + + + + + + + + + Submit + + + + + + +`; + +exports[`DefaultSlippageModal snapshot tests matches snapshot for description: description style 1`] = ` + + + + Slippage + + + + Close + + + + + + Set your slippage tolerance + + + + + + + Auto + + + + + 0.5% + + + + + 2% + + + + + 3% + + + + + Custom + + + + + + + + + + + + + + + + + Submit + + + + + + +`; + +exports[`DefaultSlippageModal snapshot tests matches snapshot for header: header style 1`] = ` + + + + Slippage + + + + Close + + + + + + Set your slippage tolerance + + + + + + + Auto + + + + + 0.5% + + + + + 2% + + + + + 3% + + + + + Custom + + + + + + + + + + + + + + + + + Submit + + + + + + +`; + +exports[`DefaultSlippageModal snapshot tests matches snapshot for submit button: submit button style 1`] = ` + + + + Slippage + + + + Close + + + + + + Set your slippage tolerance + + + + + + + Auto + + + + + 0.5% + + + + + 2% + + + + + 3% + + + + + Custom + + + + + + + + + + + + + + + + + Submit + + + + + + +`; + +exports[`DefaultSlippageModal snapshot tests matches snapshot with auto selected 1`] = ` + + + + Slippage + + + + Close + + + + + + Set your slippage tolerance + + + + + + + Auto + + + + + 0.5% + + + + + 2% + + + + + 3% + + + + + Custom + + + + + + + + + + + + + + + + + Submit + + + + + + +`; + +exports[`DefaultSlippageModal snapshot tests matches snapshot with numeric slippage selected 1`] = ` + + + + Slippage + + + + Close + + + + + + Set your slippage tolerance + + + + + + + Auto + + + + + 0.5% + + + + + 2% + + + + + 3% + + + + + Custom + + + + + + + + + + + + + + + + + Submit + + + + + + +`; diff --git a/app/components/UI/Bridge/components/SlippageModal/__snapshots__/SlippageModal.test.tsx.snap b/app/components/UI/Bridge/components/SlippageModal/__snapshots__/SlippageModal.test.tsx.snap deleted file mode 100644 index fea9d7705d7..00000000000 --- a/app/components/UI/Bridge/components/SlippageModal/__snapshots__/SlippageModal.test.tsx.snap +++ /dev/null @@ -1,583 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`SlippageModal renders all UI elements with the proper slippage options and apply button 1`] = ` - - - - - - - - - - - - - - - - - - Slippage - - - - - - - - - - - - - - - - If the price changes between the time your order is placed and confirmed it’s called “slippage.” Your swap will automatically cancel if slippage exceeds the tolerance you set here. - - - - - - - 0.5% - - - - - - - 1% - - - - - - - 2% - - - - - - - 5% - - - - - - - - - Apply - - - - - - - - -`; diff --git a/app/components/UI/Bridge/components/SlippageModal/constants.ts b/app/components/UI/Bridge/components/SlippageModal/constants.ts new file mode 100644 index 00000000000..bc2455a5d65 --- /dev/null +++ b/app/components/UI/Bridge/components/SlippageModal/constants.ts @@ -0,0 +1 @@ +export const AUTO_SLIPPAGE_VALUE = 'auto'; diff --git a/app/components/UI/Bridge/components/SlippageModal/index.tsx b/app/components/UI/Bridge/components/SlippageModal/index.tsx deleted file mode 100644 index d01791f06a3..00000000000 --- a/app/components/UI/Bridge/components/SlippageModal/index.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import React, { useRef, useState } from 'react'; -import { View } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; -import { useStyles } from '../../../../../component-library/hooks'; -import { strings } from '../../../../../../locales/i18n'; -import Text, { - TextVariant, - TextColor, -} from '../../../../../component-library/components/Texts/Text'; -import BottomSheet, { - BottomSheetRef, -} from '../../../../../component-library/components/BottomSheets/BottomSheet'; -import { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; -import createStyles from './SlippageModal.styles'; -import { SlippageOption } from './SlippageModal.types'; -import HeaderCenter from '../../../../../component-library/components-temp/HeaderCenter'; -import BottomSheetFooter from '../../../../../component-library/components/BottomSheets/BottomSheetFooter'; -import SegmentedControl from '../../../../../component-library/components-temp/SegmentedControl'; -import { useDispatch, useSelector } from 'react-redux'; -import { - selectSlippage, - setSlippage, -} from '../../../../../core/redux/slices/bridge'; - -const getSlippageOptions = (slippage: string | undefined): SlippageOption[] => { - const baseOptions = [ - { label: '1%', value: '1' }, - { label: '2%', value: '2' }, - { label: '5%', value: '5' }, - ]; - - return slippage === undefined - ? [{ label: 'Auto', value: 'auto' }, ...baseOptions] - : [{ label: '0.5%', value: '0.5' }, ...baseOptions]; -}; - -export const SlippageModal = () => { - const dispatch = useDispatch(); - - const slippage = useSelector(selectSlippage); - const slippageOptions = getSlippageOptions(slippage); - const [selectedValue, setSelectedValue] = useState(slippage || 'auto'); - const { styles } = useStyles(createStyles, {}); - const navigation = useNavigation(); - const sheetRef = useRef(null); - - const handleOptionSelected = (option: string) => { - setSelectedValue(option); - }; - - // We are setting undefined to auto slippage so that Lifi can use their default slippage for solana swaps. - const handleApply = () => { - dispatch(setSlippage(selectedValue === 'auto' ? undefined : selectedValue)); - navigation.goBack(); - }; - - const handleClose = () => { - navigation.goBack(); - }; - - return ( - - - - - {strings('bridge.slippage_info')} - - - - ({ - label: option.label, - value: option.value, - testID: `slippage-option-${option.value}`, - buttonWidth: ButtonWidthTypes.Auto, - }))} - selectedValue={selectedValue} - onValueChange={handleOptionSelected} - size={ButtonSize.Sm} - isButtonWidthFlexible - style={styles.segmentedControl} - /> - - - - - ); -}; - -export default SlippageModal; diff --git a/app/components/UI/Bridge/components/SlippageModal/styles.tsx b/app/components/UI/Bridge/components/SlippageModal/styles.tsx new file mode 100644 index 00000000000..97f36bb630d --- /dev/null +++ b/app/components/UI/Bridge/components/SlippageModal/styles.tsx @@ -0,0 +1,42 @@ +import { StyleSheet } from 'react-native'; + +export const defaultSlippageButtonGroupStyles = StyleSheet.create({ + container: { + padding: 16, + flexDirection: 'row', + gap: 8, + display: 'flex', + justifyContent: 'center', + }, +}); + +export const defaultSlippageModalStyles = StyleSheet.create({ + descriptionContainer: { + paddingHorizontal: 16, + paddingVertical: 8, + }, + descriptionText: { + textAlign: 'center', + }, + footerContainer: { + padding: 16, + }, +}); + +export const customSlippageModalStyles = StyleSheet.create({ + stepperContainer: { + padding: 16, + }, + keypadContainer: { + padding: 16, + }, + footerContainer: { + justifyContent: 'space-around', + flexDirection: 'row', + padding: 16, + gap: 12, + }, + footerContainerSection: { + flex: 1 / 2, + }, +}); diff --git a/app/components/UI/Bridge/components/SlippageModal/types.ts b/app/components/UI/Bridge/components/SlippageModal/types.ts new file mode 100644 index 00000000000..2ef5dba634f --- /dev/null +++ b/app/components/UI/Bridge/components/SlippageModal/types.ts @@ -0,0 +1,6 @@ +import { CaipChainId, Hex } from '@metamask/utils'; + +export interface DefaultSlippageModalParams { + sourceChainId?: CaipChainId | Hex; + destChainId?: CaipChainId | Hex; +} diff --git a/app/components/UI/Bridge/components/TokenInputArea/TokenInputArea.test.tsx b/app/components/UI/Bridge/components/TokenInputArea/TokenInputArea.test.tsx index 95e4c2511c8..d463fd7a2f4 100644 --- a/app/components/UI/Bridge/components/TokenInputArea/TokenInputArea.test.tsx +++ b/app/components/UI/Bridge/components/TokenInputArea/TokenInputArea.test.tsx @@ -2,12 +2,7 @@ import React from 'react'; import { initialState } from '../../_mocks_/initialState'; import { fireEvent } from '@testing-library/react-native'; import { renderScreen } from '../../../../../util/test/renderWithProvider'; -import { - TokenInputArea, - TokenInputAreaType, - calculateFontSize, - getDisplayAmount, -} from '.'; +import { TokenInputArea, TokenInputAreaType, getDisplayAmount } from '.'; import { BridgeToken } from '../../types'; import { CHAIN_IDS } from '@metamask/transaction-controller'; import { POLYGON_NATIVE_TOKEN } from '../../constants/assets'; @@ -379,33 +374,6 @@ describe('TokenInputArea', () => { }); }); -describe('calculateFontSize', () => { - it('returns 40 for lengths up to 10', () => { - expect(calculateFontSize(5)).toBe(40); - expect(calculateFontSize(10)).toBe(40); - }); - - it('returns 35 for lengths between 11 and 15', () => { - expect(calculateFontSize(11)).toBe(35); - expect(calculateFontSize(15)).toBe(35); - }); - - it('returns 30 for lengths between 16 and 20', () => { - expect(calculateFontSize(16)).toBe(30); - expect(calculateFontSize(20)).toBe(30); - }); - - it('returns 25 for lengths between 21 and 25', () => { - expect(calculateFontSize(21)).toBe(25); - expect(calculateFontSize(25)).toBe(25); - }); - - it('returns 20 for lengths greater than 25', () => { - expect(calculateFontSize(26)).toBe(20); - expect(calculateFontSize(100)).toBe(20); - }); -}); - describe('getDisplayAmount', () => { it('returns undefined for undefined input', () => { expect(getDisplayAmount(undefined)).toBeUndefined(); diff --git a/app/components/UI/Bridge/components/TokenInputArea/index.tsx b/app/components/UI/Bridge/components/TokenInputArea/index.tsx index 9ad95a8a756..c2cd91d30f2 100644 --- a/app/components/UI/Bridge/components/TokenInputArea/index.tsx +++ b/app/components/UI/Bridge/components/TokenInputArea/index.tsx @@ -26,8 +26,7 @@ import { Skeleton } from '../../../../../component-library/components/Skeleton'; import Button, { ButtonVariants, } from '../../../../../component-library/components/Buttons/Button'; -import I18n, { strings } from '../../../../../../locales/i18n'; -import { getIntlNumberFormatter } from '../../../../../util/intl'; +import { strings } from '../../../../../../locales/i18n'; import Routes from '../../../../../constants/navigation/Routes'; import { useNavigation } from '@react-navigation/native'; import { @@ -49,21 +48,12 @@ import { isNativeAddress } from '@metamask/bridge-controller'; import { Theme } from '../../../../../util/theme/models'; import parseAmount from '../../../../../util/parseAmount'; import { useTokenAddress } from '../../hooks/useTokenAddress'; +import { calculateInputFontSize } from '../../utils/calculateInputFontSize'; +import { formatAmountWithLocaleSeparators } from '../../utils/formatAmountWithLocaleSeparators'; const MAX_DECIMALS = 5; export const MAX_INPUT_LENGTH = 36; -/** - * Calculates font size based on input length - */ -export const calculateFontSize = (length: number): number => { - if (length <= 10) return 40; - if (length <= 15) return 35; - if (length <= 20) return 30; - if (length <= 25) return 25; - return 20; -}; - const createStyles = ({ vars, theme, @@ -115,37 +105,6 @@ const formatAddress = (address?: string) => { return renderShortAddress(address, 4); }; -/** - * Formats a number string with locale-appropriate separators - * Uses Intl.NumberFormat to respect user's locale (e.g., en-US uses commas, de-DE uses periods) - */ -const formatWithLocaleSeparators = (value: string): string => { - if (!value || value === '0') return value; - - const numericValue = parseFloat(value); - if (isNaN(numericValue)) return value; - - // Determine the number of decimal places in the original value - const decimalPlaces = value.includes('.') - ? value.split('.')[1]?.length || 0 - : 0; - - try { - // Format with locale-appropriate separators using user's locale - const formatted = getIntlNumberFormatter(I18n.locale, { - useGrouping: true, - minimumFractionDigits: decimalPlaces, - maximumFractionDigits: decimalPlaces, - }).format(numericValue); - - return formatted; - } catch (error) { - // Fallback to simple comma formatting if Intl fails - console.error('Number formatting error:', error); - return value; - } -}; - export const getDisplayAmount = ( amount?: string, tokenType?: TokenInputAreaType, @@ -165,7 +124,7 @@ export const getDisplayAmount = ( // Format with locale-appropriate separators if (displayAmount && displayAmount !== '0') { - return formatWithLocaleSeparators(displayAmount); + return formatAmountWithLocaleSeparators(displayAmount); } return displayAmount; @@ -321,7 +280,7 @@ export const TokenInputArea = forwardRef< : formattedAddress; const displayedAmount = getDisplayAmount(amount, tokenType, isMaxAmount); - const fontSize = calculateFontSize(displayedAmount?.length ?? 0); + const fontSize = calculateInputFontSize(displayedAmount?.length ?? 0); const { styles } = useStyles(createStyles, { fontSize, hidden: !subtitle }); let tokenButtonText = 'bridge.swap_to'; diff --git a/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/index.ts b/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/index.ts index a68cdf3e1fb..5b3a9f326b8 100644 --- a/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/index.ts +++ b/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/index.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useCallback, useMemo } from 'react'; import Engine from '../../../../../core/Engine'; import { formatAddressToCaipReference, @@ -43,6 +43,7 @@ export const useBridgeQuoteRequest = () => { decimals: sourceToken?.decimals, chainId: sourceToken?.chainId, }); + const insufficientBal = useIsInsufficientBalance({ amount: sourceAmount, token: sourceToken, @@ -53,13 +54,6 @@ export const useBridgeQuoteRequest = () => { selectGasIncludedQuoteParams, ); - // Prevents infinite requests when user select max balance on - // source token input. - const insufficientBalRef = useRef(insufficientBal); - useEffect(() => { - insufficientBalRef.current = insufficientBal; - }, [insufficientBal]); - /** * Updates quote parameters in the bridge controller */ @@ -93,7 +87,7 @@ export const useBridgeQuoteRequest = () => { destWalletAddress: destAddress ?? walletAddress, gasIncluded, gasIncluded7702, - insufficientBal: insufficientBalRef.current, + insufficientBal, }; await Engine.context.BridgeController.updateBridgeQuoteRequestParams( @@ -111,6 +105,7 @@ export const useBridgeQuoteRequest = () => { context, gasIncluded, gasIncluded7702, + insufficientBal, ]); // Create a stable debounced function that persists across renders diff --git a/app/components/UI/Bridge/hooks/useGetSlippageOptions/__snapshots__/index.test.tsx.snap b/app/components/UI/Bridge/hooks/useGetSlippageOptions/__snapshots__/index.test.tsx.snap new file mode 100644 index 00000000000..9aa85bc36fc --- /dev/null +++ b/app/components/UI/Bridge/hooks/useGetSlippageOptions/__snapshots__/index.test.tsx.snap @@ -0,0 +1,460 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`useGetSlippageOptions capitalizes the label if slippage option is not a number 1`] = ` +[ + { + "id": "auto", + "label": "Auto", + "onPress": [MockFunction], + "selected": true, + }, + { + "id": "custom", + "label": "Custom", + "onPress": [MockFunction], + "selected": false, + }, +] +`; + +exports[`useGetSlippageOptions complete output structure returns correct structure for all options with custom 1`] = ` +[ + { + "id": "auto", + "label": "Auto", + "onPress": [MockFunction], + "selected": false, + }, + { + "id": "1", + "label": "1%", + "onPress": [MockFunction], + "selected": false, + }, + { + "id": "2", + "label": "2%", + "onPress": [MockFunction], + "selected": true, + }, + { + "id": "3", + "label": "3%", + "onPress": [MockFunction], + "selected": false, + }, + { + "id": "custom-slippage", + "label": "Custom", + "onPress": [MockFunction], + "selected": false, + }, +] +`; + +exports[`useGetSlippageOptions complete output structure returns correct structure for all options without custom 1`] = ` +[ + { + "id": "auto", + "label": "Auto", + "onPress": [MockFunction], + "selected": false, + }, + { + "id": "1", + "label": "1%", + "onPress": [MockFunction], + "selected": true, + }, + { + "id": "2", + "label": "2%", + "onPress": [MockFunction], + "selected": false, + }, +] +`; + +exports[`useGetSlippageOptions complete output structure returns correct structure when custom is selected 1`] = ` +[ + { + "id": "auto", + "label": "Auto", + "onPress": [MockFunction], + "selected": false, + }, + { + "id": "1", + "label": "1%", + "onPress": [MockFunction], + "selected": false, + }, + { + "id": "2", + "label": "2%", + "onPress": [MockFunction], + "selected": false, + }, + { + "id": "custom-slippage", + "label": "Custom", + "onPress": [MockFunction], + "selected": true, + }, +] +`; + +exports[`useGetSlippageOptions does not includes custom option if allowCustomSlippage is false 1`] = ` +[ + { + "id": "auto", + "label": "Auto", + "onPress": [MockFunction], + "selected": false, + }, + { + "id": "1", + "label": "1%", + "onPress": [MockFunction], + "selected": false, + }, + { + "id": "2", + "label": "2%", + "onPress": [MockFunction], + "selected": true, + }, + { + "id": "3", + "label": "3%", + "onPress": [MockFunction], + "selected": false, + }, +] +`; + +exports[`useGetSlippageOptions does not render custom option if allowCustomSlippage is not provided 1`] = ` +[ + { + "id": "auto", + "label": "Auto", + "onPress": [MockFunction], + "selected": false, + }, + { + "id": "1", + "label": "1%", + "onPress": [MockFunction], + "selected": false, + }, + { + "id": "2", + "label": "2%", + "onPress": [MockFunction], + "selected": true, + }, + { + "id": "3", + "label": "3%", + "onPress": [MockFunction], + "selected": false, + }, +] +`; + +exports[`useGetSlippageOptions does not render custom option if onCustomOptionPress is not provided 1`] = ` +[ + { + "id": "auto", + "label": "Auto", + "onPress": [MockFunction], + "selected": false, + }, + { + "id": "1", + "label": "1%", + "onPress": [MockFunction], + "selected": false, + }, + { + "id": "2", + "label": "2%", + "onPress": [MockFunction], + "selected": true, + }, + { + "id": "3", + "label": "3%", + "onPress": [MockFunction], + "selected": false, + }, +] +`; + +exports[`useGetSlippageOptions edge cases handles decimal values 1`] = ` +[ + { + "id": "0.5", + "label": "0.5%", + "onPress": [MockFunction], + "selected": false, + }, + { + "id": "1.5", + "label": "1.5%", + "onPress": [MockFunction], + "selected": true, + }, + { + "id": "2.5", + "label": "2.5%", + "onPress": [MockFunction], + "selected": false, + }, +] +`; + +exports[`useGetSlippageOptions edge cases handles empty slippageOptions array 1`] = `[]`; + +exports[`useGetSlippageOptions edge cases handles large numbers 1`] = ` +[ + { + "id": "10", + "label": "10%", + "onPress": [MockFunction], + "selected": false, + }, + { + "id": "50", + "label": "50%", + "onPress": [MockFunction], + "selected": true, + }, + { + "id": "100", + "label": "100%", + "onPress": [MockFunction], + "selected": false, + }, +] +`; + +exports[`useGetSlippageOptions edge cases handles mixed case string options 1`] = ` +[ + { + "id": "AUTO", + "label": "Auto", + "onPress": [MockFunction], + "selected": true, + }, + { + "id": "custom", + "label": "Custom", + "onPress": [MockFunction], + "selected": false, + }, + { + "id": "Default", + "label": "Default", + "onPress": [MockFunction], + "selected": false, + }, +] +`; + +exports[`useGetSlippageOptions edge cases handles string coercion for selection 1`] = ` +[ + { + "id": "1", + "label": "1%", + "onPress": [MockFunction], + "selected": false, + }, + { + "id": "2", + "label": "2%", + "onPress": [MockFunction], + "selected": true, + }, + { + "id": "3", + "label": "3%", + "onPress": [MockFunction], + "selected": false, + }, +] +`; + +exports[`useGetSlippageOptions edge cases handles zero value 1`] = ` +[ + { + "id": "0", + "label": "0%", + "onPress": [MockFunction], + "selected": true, + }, + { + "id": "1", + "label": "1%", + "onPress": [MockFunction], + "selected": false, + }, + { + "id": "2", + "label": "2%", + "onPress": [MockFunction], + "selected": false, + }, +] +`; + +exports[`useGetSlippageOptions includes custom option if allowCustomSlippage is true 1`] = ` +[ + { + "id": "auto", + "label": "Auto", + "onPress": [MockFunction], + "selected": false, + }, + { + "id": "1", + "label": "1%", + "onPress": [MockFunction], + "selected": false, + }, + { + "id": "2", + "label": "2%", + "onPress": [MockFunction], + "selected": true, + }, + { + "id": "3", + "label": "3%", + "onPress": [MockFunction], + "selected": false, + }, + { + "id": "custom-slippage", + "label": "Custom", + "onPress": [MockFunction], + "selected": false, + }, +] +`; + +exports[`useGetSlippageOptions set custom slippage option if slippage value does not exist on slippageOptions array 1`] = ` +[ + { + "id": "auto", + "label": "Auto", + "onPress": [MockFunction], + "selected": false, + }, + { + "id": "1", + "label": "1%", + "onPress": [MockFunction], + "selected": false, + }, + { + "id": "2", + "label": "2%", + "onPress": [MockFunction], + "selected": false, + }, + { + "id": "3", + "label": "3%", + "onPress": [MockFunction], + "selected": false, + }, + { + "id": "custom-slippage", + "label": "Custom", + "onPress": [MockFunction], + "selected": true, + }, +] +`; + +exports[`useGetSlippageOptions set selected default slippage option if slippage value exist in slippageOptions array 1`] = ` +[ + { + "id": "auto", + "label": "Auto", + "onPress": [MockFunction], + "selected": false, + }, + { + "id": "1", + "label": "1%", + "onPress": [MockFunction], + "selected": false, + }, + { + "id": "2", + "label": "2%", + "onPress": [MockFunction], + "selected": true, + }, + { + "id": "3", + "label": "3%", + "onPress": [MockFunction], + "selected": false, + }, +] +`; + +exports[`useGetSlippageOptions sets slippage option value as label if it is a number 1`] = ` +[ + { + "id": "1", + "label": "1%", + "onPress": [MockFunction], + "selected": false, + }, + { + "id": "2", + "label": "2%", + "onPress": [MockFunction], + "selected": true, + }, + { + "id": "5", + "label": "5%", + "onPress": [MockFunction], + "selected": false, + }, + { + "id": "10", + "label": "10%", + "onPress": [MockFunction], + "selected": false, + }, +] +`; + +exports[`useGetSlippageOptions should handle "auto" as slippage option 1`] = ` +[ + { + "id": "auto", + "label": "Auto", + "onPress": [MockFunction], + "selected": true, + }, + { + "id": "1", + "label": "1%", + "onPress": [MockFunction], + "selected": false, + }, + { + "id": "2", + "label": "2%", + "onPress": [MockFunction], + "selected": false, + }, +] +`; diff --git a/app/components/UI/Bridge/hooks/useGetSlippageOptions/index.test.tsx b/app/components/UI/Bridge/hooks/useGetSlippageOptions/index.test.tsx new file mode 100644 index 00000000000..6772e7a69bc --- /dev/null +++ b/app/components/UI/Bridge/hooks/useGetSlippageOptions/index.test.tsx @@ -0,0 +1,341 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useGetSlippageOptions } from './index'; + +describe('useGetSlippageOptions', () => { + const defaultProps = { + slippageOptions: ['auto', '1', '2', '3'] as const, + slippage: '2', + onDefaultOptionPress: jest.fn(() => jest.fn()), + onCustomOptionPress: jest.fn(), + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('does not includes custom option if allowCustomSlippage is false', () => { + const { result } = renderHook(() => + useGetSlippageOptions({ + ...defaultProps, + allowCustomSlippage: false, + }), + ); + + const hasCustomOption = result.current.some( + (option) => option.id === 'custom-slippage', + ); + expect(hasCustomOption).toBe(false); + expect(result.current).toMatchSnapshot(); + }); + + it('includes custom option if allowCustomSlippage is true', () => { + const { result } = renderHook(() => + useGetSlippageOptions({ + ...defaultProps, + allowCustomSlippage: true, + }), + ); + + const customOption = result.current.find( + (option) => option.id === 'custom-slippage', + ); + expect(customOption).toBeDefined(); + expect(customOption?.label).toBe('Custom'); + expect(result.current).toMatchSnapshot(); + }); + + it('capitalizes the label if slippage option is not a number', () => { + const { result } = renderHook(() => + useGetSlippageOptions({ + ...defaultProps, + slippageOptions: ['auto', 'custom'], + slippage: 'auto', + }), + ); + + expect(result.current[0].label).toBe('Auto'); + expect(result.current[1].label).toBe('Custom'); + expect(result.current).toMatchSnapshot(); + }); + + it('sets slippage option value as label if it is a number', () => { + const { result } = renderHook(() => + useGetSlippageOptions({ + ...defaultProps, + slippageOptions: ['1', '2', '5', '10'], + slippage: '2', + }), + ); + + expect(result.current[0].label).toBe('1%'); + expect(result.current[1].label).toBe('2%'); + expect(result.current[2].label).toBe('5%'); + expect(result.current[3].label).toBe('10%'); + expect(result.current).toMatchSnapshot(); + }); + + it('calls onDefaultOptionPress with correct numeric value', () => { + const onDefaultOptionPress = jest.fn(() => jest.fn()); + const { result } = renderHook(() => + useGetSlippageOptions({ + ...defaultProps, + slippageOptions: ['1', '2', '3'], + onDefaultOptionPress, + }), + ); + + result.current[0].onPress(); + + expect(onDefaultOptionPress).toHaveBeenCalledWith('1'); + }); + + it('calls onDefaultOptionPress with correct non numeric value (eg. auto)', () => { + const onDefaultOptionPress = jest.fn(() => jest.fn()); + const { result } = renderHook(() => + useGetSlippageOptions({ + ...defaultProps, + slippageOptions: ['auto', '1', '2'], + onDefaultOptionPress, + }), + ); + + result.current[0].onPress(); + + expect(onDefaultOptionPress).toHaveBeenCalledWith('auto'); + }); + + it('set selected default slippage option if slippage value exist in slippageOptions array', () => { + const { result } = renderHook(() => + useGetSlippageOptions({ + ...defaultProps, + slippageOptions: ['auto', '1', '2', '3'], + slippage: '2', + }), + ); + + expect(result.current[0].selected).toBe(false); // auto + expect(result.current[1].selected).toBe(false); // 1 + expect(result.current[2].selected).toBe(true); // 2 - selected + expect(result.current[3].selected).toBe(false); // 3 + expect(result.current).toMatchSnapshot(); + }); + + it('set custom slippage option if slippage value does not exist on slippageOptions array', () => { + const { result } = renderHook(() => + useGetSlippageOptions({ + ...defaultProps, + allowCustomSlippage: true, + slippageOptions: ['auto', '1', '2', '3'], + slippage: '5.5', // Custom value not in options + }), + ); + + const customOption = result.current.find( + (option) => option.id === 'custom-slippage', + ); + expect(customOption?.selected).toBe(true); + + // All default options should be false + const defaultOptions = result.current.filter( + (option) => option.id !== 'custom-slippage', + ); + defaultOptions.forEach((option) => { + expect(option.selected).toBe(false); + }); + + expect(result.current).toMatchSnapshot(); + }); + + it('provides custom option press callback to custom option array element', () => { + const onCustomOptionPress = jest.fn(); + const { result } = renderHook(() => + useGetSlippageOptions({ + ...defaultProps, + allowCustomSlippage: true, + onCustomOptionPress, + }), + ); + + const customOption = result.current.find( + (option) => option.id === 'custom-slippage', + ); + customOption?.onPress(); + + expect(onCustomOptionPress).toHaveBeenCalledTimes(1); + }); + + it('does not render custom option if onCustomOptionPress is not provided', () => { + const { result } = renderHook(() => + useGetSlippageOptions({ + ...defaultProps, + allowCustomSlippage: true, + onCustomOptionPress: undefined, + }), + ); + + const customOption = result.current.find( + (option) => option.id === 'custom-slippage', + ); + expect(customOption).toBeUndefined(); + expect(result.current).toMatchSnapshot(); + }); + + it('does not render custom option if allowCustomSlippage is not provided', () => { + const { result } = renderHook(() => + useGetSlippageOptions({ + ...defaultProps, + allowCustomSlippage: undefined, + }), + ); + + const customOption = result.current.find( + (option) => option.id === 'custom-slippage', + ); + expect(customOption).toBeUndefined(); + expect(result.current).toMatchSnapshot(); + }); + + it('should handle "auto" as slippage option', () => { + const { result } = renderHook(() => + useGetSlippageOptions({ + ...defaultProps, + slippageOptions: ['auto', '1', '2'], + slippage: 'auto', + }), + ); + + expect(result.current[0].id).toBe('auto'); + expect(result.current[0].label).toBe('Auto'); + expect(result.current[0].selected).toBe(true); + expect(result.current).toMatchSnapshot(); + }); + + describe('edge cases', () => { + it('handles empty slippageOptions array', () => { + const { result } = renderHook(() => + useGetSlippageOptions({ + ...defaultProps, + slippageOptions: [], + slippage: '1', + }), + ); + + expect(result.current).toHaveLength(0); + expect(result.current).toMatchSnapshot(); + }); + + it('handles decimal values', () => { + const { result } = renderHook(() => + useGetSlippageOptions({ + ...defaultProps, + slippageOptions: ['0.5', '1.5', '2.5'], + slippage: '1.5', + }), + ); + + expect(result.current[0].label).toBe('0.5%'); + expect(result.current[1].label).toBe('1.5%'); + expect(result.current[1].selected).toBe(true); + expect(result.current).toMatchSnapshot(); + }); + + it('handles large numbers', () => { + const { result } = renderHook(() => + useGetSlippageOptions({ + ...defaultProps, + slippageOptions: ['10', '50', '100'], + slippage: '50', + }), + ); + + expect(result.current[0].label).toBe('10%'); + expect(result.current[1].label).toBe('50%'); + expect(result.current[2].label).toBe('100%'); + expect(result.current).toMatchSnapshot(); + }); + + it('handles zero value', () => { + const { result } = renderHook(() => + useGetSlippageOptions({ + ...defaultProps, + slippageOptions: ['0', '1', '2'], + slippage: '0', + }), + ); + + expect(result.current[0].label).toBe('0%'); + expect(result.current[0].selected).toBe(true); + expect(result.current).toMatchSnapshot(); + }); + + it('handles string coercion for selection', () => { + const { result } = renderHook(() => + useGetSlippageOptions({ + ...defaultProps, + slippageOptions: ['1', '2', '3'], + slippage: '2', + }), + ); + + // Should match even if types differ + expect(result.current[1].selected).toBe(true); + expect(result.current).toMatchSnapshot(); + }); + + it('handles mixed case string options', () => { + const { result } = renderHook(() => + useGetSlippageOptions({ + ...defaultProps, + slippageOptions: ['AUTO', 'custom', 'Default'], + slippage: 'AUTO', + }), + ); + + expect(result.current[0].label).toBe('Auto'); + expect(result.current[1].label).toBe('Custom'); + expect(result.current[2].label).toBe('Default'); + expect(result.current).toMatchSnapshot(); + }); + }); + + describe('complete output structure', () => { + it('returns correct structure for all options without custom', () => { + const { result } = renderHook(() => + useGetSlippageOptions({ + ...defaultProps, + slippageOptions: ['auto', '1', '2'], + slippage: '1', + allowCustomSlippage: false, + }), + ); + + expect(result.current).toMatchSnapshot(); + }); + + it('returns correct structure for all options with custom', () => { + const { result } = renderHook(() => + useGetSlippageOptions({ + ...defaultProps, + slippageOptions: ['auto', '1', '2', '3'], + slippage: '2', + allowCustomSlippage: true, + }), + ); + + expect(result.current).toMatchSnapshot(); + }); + + it('returns correct structure when custom is selected', () => { + const { result } = renderHook(() => + useGetSlippageOptions({ + ...defaultProps, + slippageOptions: ['auto', '1', '2'], + slippage: '5.75', + allowCustomSlippage: true, + }), + ); + + expect(result.current).toMatchSnapshot(); + }); + }); +}); diff --git a/app/components/UI/Bridge/hooks/useGetSlippageOptions/index.ts b/app/components/UI/Bridge/hooks/useGetSlippageOptions/index.ts new file mode 100644 index 00000000000..70dda668f79 --- /dev/null +++ b/app/components/UI/Bridge/hooks/useGetSlippageOptions/index.ts @@ -0,0 +1,47 @@ +import { capitalize } from 'lodash'; +import { strings } from '../../../../../../locales/i18n'; +import { SlippageType } from '../../types'; +import { useMemo } from 'react'; + +interface Props { + allowCustomSlippage?: boolean; + slippageOptions: readonly string[]; + slippage: string; + onDefaultOptionPress: (value: SlippageType) => () => void; + onCustomOptionPress?: () => void; +} + +export const useGetSlippageOptions = ({ + allowCustomSlippage, + slippageOptions, + slippage, + onDefaultOptionPress, + onCustomOptionPress, +}: Props) => + useMemo(() => { + const options = slippageOptions.map((value) => ({ + id: String(value), + label: isNaN(parseFloat(value)) ? capitalize(value) : value + '%', + onPress: onDefaultOptionPress(value), + selected: String(value) === String(slippage), + })); + + if (allowCustomSlippage && onCustomOptionPress) { + options.push({ + id: 'custom-slippage', + label: strings('bridge.custom'), + onPress: onCustomOptionPress, + selected: !slippageOptions.some( + (value) => String(value) === String(slippage), + ), + }); + } + + return options; + }, [ + allowCustomSlippage, + slippageOptions, + slippage, + onDefaultOptionPress, + onCustomOptionPress, + ]); diff --git a/app/components/UI/Bridge/hooks/useInsufficientBalance/index.ts b/app/components/UI/Bridge/hooks/useInsufficientBalance/index.ts index 6c6afcd55cc..d62d2515cfe 100644 --- a/app/components/UI/Bridge/hooks/useInsufficientBalance/index.ts +++ b/app/components/UI/Bridge/hooks/useInsufficientBalance/index.ts @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { useSelector } from 'react-redux'; import { selectMinSolBalance } from '../../../../../selectors/bridgeController'; import { parseUnits } from 'ethers/lib/utils'; @@ -50,84 +51,99 @@ const useIsInsufficientBalance = ({ const quotes = useSelector(selectBridgeQuotes); const minSolBalance = useSelector(selectMinSolBalance); + // Extract only the required data from quote to prevent + // uneccessary rerenders that can use infinite loops. const bestQuote = quotes?.recommendedQuote; - const { gasIncluded, gasIncluded7702, gasSponsored } = bestQuote?.quote ?? {}; - const isGasless = gasIncluded7702 || gasIncluded; - - const isValidAmount = - amount !== undefined && amount !== '.' && token?.decimals; - - // If we don't have valid inputs yet, return false (no error) - if (!isValidAmount || !token) { - return false; - } + const gasIncluded = bestQuote?.quote?.gasIncluded; + const gasIncluded7702 = bestQuote?.quote?.gasIncluded7702; + const gasSponsored = bestQuote?.quote?.gasSponsored; + const gasAmount = bestQuote?.gasFee?.effective?.amount; + + return useMemo(() => { + const isValidAmount = + amount !== undefined && amount !== '.' && token?.decimals; + + // If we don't have valid inputs yet, return false (no error) + if (!isValidAmount || !token) { + return false; + } - // If we don't have balance data yet (e.g., during token switching), - // return true to prevent invalid swaps until balance is loaded - if (!latestAtomicBalance) { - return true; - } + // If we don't have balance data yet (e.g., during token switching), + // return true to prevent invalid swaps until balance is loaded + if (!latestAtomicBalance) { + return true; + } - // Normalize amount to token decimals and handle excess decimals - let inputAmount: BigNumber; - try { - const normalizedAmount = normalizeAmount(amount, token.decimals); - inputAmount = parseAmount(normalizedAmount, token.decimals); - } catch { - // If we can't parse the amount, treat it as invalid (insufficient balance) - return true; - } + // Normalize amount to token decimals and handle excess decimals + let inputAmount: BigNumber; + try { + const normalizedAmount = normalizeAmount(amount, token.decimals); + inputAmount = parseAmount(normalizedAmount, token.decimals); + } catch { + // If we can't parse the amount, treat it as invalid (insufficient balance) + return true; + } - const isNativeToken = token.chainId && isNativeAddress(token.address); - const isSOL = isNativeToken && isSolanaChainId(token.chainId); - - // Calculate gas fees if needed for native token source - // For ERC-20 tokens (USDC, DAI, etc.), gas is checked separately in useHasSufficientGas - // For native tokens (ETH, MATIC, etc.), gas comes from the SAME balance we're spending, - // so we need to ensure: balance >= sourceAmount + gasAmount - // NOTE: If gas is sponsored/included, we skip adding gas to the calculation but still check token balance - let atomicGasFee = BigNumber.from(0); - if (isNativeToken && !isGasless && !gasSponsored) { - const gasAmount = bestQuote?.gasFee?.effective?.amount; - const effectiveGasFee = isNumberValue(gasAmount) - ? // we guard against null and undefined values of gasAmount when checked isNumberValue - new BigNumberJS(gasAmount as string | number).toFixed() - : null; - - if (effectiveGasFee) { - atomicGasFee = parseAmount(effectiveGasFee, token.decimals); + const isNativeToken = token.chainId && isNativeAddress(token.address); + const isSOL = isNativeToken && isSolanaChainId(token.chainId); + const isGasless = gasIncluded7702 || gasIncluded; + + // Calculate gas fees if needed for native token source + // For ERC-20 tokens (USDC, DAI, etc.), gas is checked separately in useHasSufficientGas + // For native tokens (ETH, MATIC, etc.), gas comes from the SAME balance we're spending, + // so we need to ensure: balance >= sourceAmount + gasAmount + // NOTE: If gas is sponsored/included, we skip adding gas to the calculation but still check token balance + let atomicGasFee = BigNumber.from(0); + if (isNativeToken && !isGasless && !gasSponsored) { + const effectiveGasFee = isNumberValue(gasAmount) + ? // we guard against null and undefined values of gasAmount when checked isNumberValue + new BigNumberJS(gasAmount as string | number).toFixed() + : null; + + if (effectiveGasFee) { + atomicGasFee = parseAmount(effectiveGasFee, token.decimals); + } } - } - let isInsufficientBalance = false; - - if (isSOL) { - // SOL: check if balance - inputAmount >= minSolBalance (rent exemption) - const minSolBalanceLamports = parseUnits(minSolBalance, token.decimals); - const remainingBalance = latestAtomicBalance.sub(inputAmount); - isInsufficientBalance = - isInsufficientBalance || remainingBalance.lt(minSolBalanceLamports); - } else if ( - isNativeToken && - !isGasless && - !gasSponsored && - atomicGasFee.gt(0) - ) { - // Native tokens (ETH, MATIC, etc.): check balance >= sourceAmount + gasAmount - // Example: User has 1 ETH, wants to send 1 ETH, needs 0.01 ETH gas = insufficient - const totalRequired = inputAmount.add(atomicGasFee); - isInsufficientBalance = - isInsufficientBalance || totalRequired.gt(latestAtomicBalance); - } else { - // All other cases: ERC-20 tokens, gasless transactions, or gas-sponsored transactions - // Check balance >= sourceAmount only (gas is either not needed or checked separately) - // Example: User has 100 USDC, wants to send 50 USDC = sufficient - // Example: User has 0.0004 BTC, wants to send 0.0114 BTC (gasless) = insufficient - isInsufficientBalance = - isInsufficientBalance || inputAmount.gt(latestAtomicBalance); - } + let isInsufficientBalance = false; + + if (isSOL) { + // SOL: check if balance - inputAmount >= minSolBalance (rent exemption) + const minSolBalanceLamports = parseUnits(minSolBalance, token.decimals); + const remainingBalance = latestAtomicBalance.sub(inputAmount); + isInsufficientBalance = + isInsufficientBalance || remainingBalance.lt(minSolBalanceLamports); + } else if ( + isNativeToken && + !isGasless && + !gasSponsored && + atomicGasFee.gt(0) + ) { + // Native tokens (ETH, MATIC, etc.): check balance >= sourceAmount + gasAmount + // Example: User has 1 ETH, wants to send 1 ETH, needs 0.01 ETH gas = insufficient + const totalRequired = inputAmount.add(atomicGasFee); + isInsufficientBalance = + isInsufficientBalance || totalRequired.gt(latestAtomicBalance); + } else { + // All other cases: ERC-20 tokens, gasless transactions, or gas-sponsored transactions + // Check balance >= sourceAmount only (gas is either not needed or checked separately) + // Example: User has 100 USDC, wants to send 50 USDC = sufficient + // Example: User has 0.0004 BTC, wants to send 0.0114 BTC (gasless) = insufficient + isInsufficientBalance = + isInsufficientBalance || inputAmount.gt(latestAtomicBalance); + } - return Boolean(isInsufficientBalance); + return Boolean(isInsufficientBalance); + }, [ + amount, + token, + latestAtomicBalance, + gasIncluded, + gasIncluded7702, + gasSponsored, + gasAmount, + minSolBalance, + ]); }; export default useIsInsufficientBalance; diff --git a/app/components/UI/Bridge/hooks/useShouldDisableCustomSlippageConfirm/index.test.tsx b/app/components/UI/Bridge/hooks/useShouldDisableCustomSlippageConfirm/index.test.tsx new file mode 100644 index 00000000000..4c162b7f0d8 --- /dev/null +++ b/app/components/UI/Bridge/hooks/useShouldDisableCustomSlippageConfirm/index.test.tsx @@ -0,0 +1,385 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useShouldDisableCustomSlippageConfirm } from './index'; +import { BridgeSlippageConfig } from '../../types'; + +describe('useShouldDisableCustomSlippageConfirm', () => { + const defaultSlippageConfig: BridgeSlippageConfig['__default__'] = { + input_step: 1, + max_amount: 100, + min_amount: 0, + input_max_decimals: 2, + lower_allowed_slippage_threshold: { + messageId: 'bridge.lower_allowed_error', + value: 0.5, + inclusive: true, + }, + lower_suggested_slippage_threshold: null, + upper_suggested_slippage_threshold: null, + upper_allowed_slippage_threshold: { + messageId: 'bridge.upper_allowed_error', + value: 50, + inclusive: true, + }, + default_slippage_options: ['auto', '1', '2', '3'], + has_custom_slippage_option: true, + }; + + it('disables confirm if value is more than max amount', () => { + const { result } = renderHook(() => + useShouldDisableCustomSlippageConfirm({ + inputAmount: '101', + slippageConfig: defaultSlippageConfig, + }), + ); + + expect(result.current).toBe(true); + }); + + it('disables confirm if value is less than min amount', () => { + const { result } = renderHook(() => + useShouldDisableCustomSlippageConfirm({ + inputAmount: '-1', + slippageConfig: defaultSlippageConfig, + }), + ); + + expect(result.current).toBe(true); + }); + + it('disables confirm if inputAmount is at max value', () => { + const { result } = renderHook(() => + useShouldDisableCustomSlippageConfirm({ + inputAmount: '100', + slippageConfig: defaultSlippageConfig, + }), + ); + + // Value at max is valid (not more than), but violates upper threshold (50, inclusive) + expect(result.current).toBe(true); + }); + + it('disables confirm if inputAmount exceeds upper allowed slippage threshold', () => { + const { result } = renderHook(() => + useShouldDisableCustomSlippageConfirm({ + inputAmount: '51', + slippageConfig: defaultSlippageConfig, + }), + ); + + expect(result.current).toBe(true); + }); + + it('disables confirm if inputAmount violates lower allowed slippage threshold', () => { + const { result } = renderHook(() => + useShouldDisableCustomSlippageConfirm({ + inputAmount: '0.3', + slippageConfig: defaultSlippageConfig, + }), + ); + + expect(result.current).toBe(true); + }); + + it('enables confirm if upper allowed slippage threshold is not defined', () => { + const configWithoutUpper: BridgeSlippageConfig['__default__'] = { + ...defaultSlippageConfig, + upper_allowed_slippage_threshold: null, + }; + + const { result } = renderHook(() => + useShouldDisableCustomSlippageConfirm({ + inputAmount: '99', + slippageConfig: configWithoutUpper, + }), + ); + + // Should not disable since there's no upper threshold + expect(result.current).toBe(false); + }); + + it('enables confirm if lower allowed slippage threshold is not defined', () => { + const configWithoutLower: BridgeSlippageConfig['__default__'] = { + ...defaultSlippageConfig, + lower_allowed_slippage_threshold: null, + }; + + const { result } = renderHook(() => + useShouldDisableCustomSlippageConfirm({ + inputAmount: '0.3', + slippageConfig: configWithoutLower, + }), + ); + + // Should not disable since there's no lower threshold + expect(result.current).toBe(false); + }); + + it('handles inclusive and exclusive lower allowed slippage threshold range', () => { + // Test inclusive (value <= threshold) + const inclusiveConfig: BridgeSlippageConfig['__default__'] = { + ...defaultSlippageConfig, + lower_allowed_slippage_threshold: { + messageId: 'bridge.lower_allowed_error', + value: 1, + inclusive: true, + }, + }; + + const { result: inclusiveResult } = renderHook(() => + useShouldDisableCustomSlippageConfirm({ + inputAmount: '1', + slippageConfig: inclusiveConfig, + }), + ); + + // With inclusive, value === threshold should disable + expect(inclusiveResult.current).toBe(true); + + // Test exclusive (value < threshold) + const exclusiveConfig: BridgeSlippageConfig['__default__'] = { + ...defaultSlippageConfig, + lower_allowed_slippage_threshold: { + messageId: 'bridge.lower_allowed_error', + value: 1, + inclusive: false, + }, + }; + + const { result: exclusiveResult } = renderHook(() => + useShouldDisableCustomSlippageConfirm({ + inputAmount: '1', + slippageConfig: exclusiveConfig, + }), + ); + + // With exclusive, value === threshold should NOT disable + expect(exclusiveResult.current).toBe(false); + }); + + it('handles inclusive and exclusive upper allowed slippage threshold range', () => { + // Test inclusive (value >= threshold) + const inclusiveConfig: BridgeSlippageConfig['__default__'] = { + ...defaultSlippageConfig, + upper_allowed_slippage_threshold: { + messageId: 'bridge.upper_allowed_error', + value: 50, + inclusive: true, + }, + }; + + const { result: inclusiveResult } = renderHook(() => + useShouldDisableCustomSlippageConfirm({ + inputAmount: '50', + slippageConfig: inclusiveConfig, + }), + ); + + // With inclusive, value === threshold should disable + expect(inclusiveResult.current).toBe(true); + + // Test exclusive (value > threshold) + const exclusiveConfig: BridgeSlippageConfig['__default__'] = { + ...defaultSlippageConfig, + upper_allowed_slippage_threshold: { + messageId: 'bridge.upper_allowed_error', + value: 50, + inclusive: false, + }, + }; + + const { result: exclusiveResult } = renderHook(() => + useShouldDisableCustomSlippageConfirm({ + inputAmount: '50', + slippageConfig: exclusiveConfig, + }), + ); + + // With exclusive, value === threshold should NOT disable + expect(exclusiveResult.current).toBe(false); + }); + + describe('edge cases', () => { + it('enables confirm when value is within valid range', () => { + const { result } = renderHook(() => + useShouldDisableCustomSlippageConfirm({ + inputAmount: '5', + slippageConfig: defaultSlippageConfig, + }), + ); + + expect(result.current).toBe(false); + }); + + it('handles value at exact min_amount', () => { + const { result } = renderHook(() => + useShouldDisableCustomSlippageConfirm({ + inputAmount: '0', + slippageConfig: defaultSlippageConfig, + }), + ); + + // Value at min is valid (not less than) + expect(result.current).toBe(true); // But violates lower_allowed_slippage_threshold + }); + + it('handles value at exact max_amount', () => { + const { result } = renderHook(() => + useShouldDisableCustomSlippageConfirm({ + inputAmount: '100', + slippageConfig: defaultSlippageConfig, + }), + ); + + // Value at max is valid (not more than), but violates upper threshold + expect(result.current).toBe(true); + }); + + it('handles decimal values', () => { + const { result } = renderHook(() => + useShouldDisableCustomSlippageConfirm({ + inputAmount: '2.5', + slippageConfig: defaultSlippageConfig, + }), + ); + + expect(result.current).toBe(false); + }); + + it('handles zero value', () => { + const configWithZeroMin: BridgeSlippageConfig['__default__'] = { + ...defaultSlippageConfig, + min_amount: 0, + lower_allowed_slippage_threshold: null, + }; + + const { result } = renderHook(() => + useShouldDisableCustomSlippageConfirm({ + inputAmount: '0', + slippageConfig: configWithZeroMin, + }), + ); + + expect(result.current).toBe(false); + }); + + it('handles empty string as input', () => { + const { result } = renderHook(() => + useShouldDisableCustomSlippageConfirm({ + inputAmount: '', + slippageConfig: defaultSlippageConfig, + }), + ); + + // parseFloat('') returns NaN, which fails all comparisons + expect(result.current).toBe(false); + }); + + it('handles invalid numeric string', () => { + const { result } = renderHook(() => + useShouldDisableCustomSlippageConfirm({ + inputAmount: 'abc', + slippageConfig: defaultSlippageConfig, + }), + ); + + // parseFloat('abc') returns NaN + expect(result.current).toBe(false); + }); + + it('handles both thresholds as null', () => { + const configWithoutThresholds: BridgeSlippageConfig['__default__'] = { + ...defaultSlippageConfig, + lower_allowed_slippage_threshold: null, + upper_allowed_slippage_threshold: null, + }; + + const { result } = renderHook(() => + useShouldDisableCustomSlippageConfirm({ + inputAmount: '50', + slippageConfig: configWithoutThresholds, + }), + ); + + // Should only check min/max amounts + expect(result.current).toBe(false); + }); + }); + + describe('boundary testing', () => { + it('tests lower threshold exclusive boundary', () => { + const config: BridgeSlippageConfig['__default__'] = { + ...defaultSlippageConfig, + lower_allowed_slippage_threshold: { + messageId: 'bridge.lower_allowed_error', + value: 1, + inclusive: false, + }, + }; + + // Just below threshold (should disable) + const { result: below } = renderHook(() => + useShouldDisableCustomSlippageConfirm({ + inputAmount: '0.9', + slippageConfig: config, + }), + ); + expect(below.current).toBe(true); + + // At threshold (should NOT disable with exclusive) + const { result: at } = renderHook(() => + useShouldDisableCustomSlippageConfirm({ + inputAmount: '1', + slippageConfig: config, + }), + ); + expect(at.current).toBe(false); + + // Above threshold (should NOT disable) + const { result: above } = renderHook(() => + useShouldDisableCustomSlippageConfirm({ + inputAmount: '1.1', + slippageConfig: config, + }), + ); + expect(above.current).toBe(false); + }); + + it('tests upper threshold exclusive boundary', () => { + const config: BridgeSlippageConfig['__default__'] = { + ...defaultSlippageConfig, + upper_allowed_slippage_threshold: { + messageId: 'bridge.upper_allowed_error', + value: 50, + inclusive: false, + }, + }; + + // Below threshold (should NOT disable) + const { result: below } = renderHook(() => + useShouldDisableCustomSlippageConfirm({ + inputAmount: '49', + slippageConfig: config, + }), + ); + expect(below.current).toBe(false); + + // At threshold (should NOT disable with exclusive) + const { result: at } = renderHook(() => + useShouldDisableCustomSlippageConfirm({ + inputAmount: '50', + slippageConfig: config, + }), + ); + expect(at.current).toBe(false); + + // Above threshold (should disable) + const { result: above } = renderHook(() => + useShouldDisableCustomSlippageConfirm({ + inputAmount: '51', + slippageConfig: config, + }), + ); + expect(above.current).toBe(true); + }); + }); +}); diff --git a/app/components/UI/Bridge/hooks/useShouldDisableCustomSlippageConfirm/index.ts b/app/components/UI/Bridge/hooks/useShouldDisableCustomSlippageConfirm/index.ts new file mode 100644 index 00000000000..cb6f9708ef2 --- /dev/null +++ b/app/components/UI/Bridge/hooks/useShouldDisableCustomSlippageConfirm/index.ts @@ -0,0 +1,49 @@ +import { useCallback, useMemo } from 'react'; +import { BridgeSlippageConfig } from '../../types'; + +interface Props { + inputAmount: string; + slippageConfig: BridgeSlippageConfig['__default__']; +} + +export const useShouldDisableCustomSlippageConfirm = ({ + inputAmount, + slippageConfig, +}: Props) => { + const value = parseFloat(inputAmount); + + const violatesThreshold = useCallback( + ( + threshold: { value: number; inclusive: boolean } | null, + compare: (v: number, t: number) => boolean, + ): boolean => { + if (!threshold) return false; + return threshold.inclusive + ? compare(value, threshold.value) || value === threshold.value + : compare(value, threshold.value); + }, + [value], + ); + + return useMemo( + () => + value > slippageConfig.max_amount || + value < slippageConfig.min_amount || + violatesThreshold( + slippageConfig.upper_allowed_slippage_threshold, + (v, t) => v > t, + ) || + violatesThreshold( + slippageConfig.lower_allowed_slippage_threshold, + (v, t) => v < t, + ), + [ + value, + slippageConfig.max_amount, + slippageConfig.min_amount, + slippageConfig.upper_allowed_slippage_threshold, + slippageConfig.lower_allowed_slippage_threshold, + violatesThreshold, + ], + ); +}; diff --git a/app/components/UI/Bridge/hooks/useSlippageConfig/__snapshots__/index.test.tsx.snap b/app/components/UI/Bridge/hooks/useSlippageConfig/__snapshots__/index.test.tsx.snap new file mode 100644 index 00000000000..e514114f4f0 --- /dev/null +++ b/app/components/UI/Bridge/hooks/useSlippageConfig/__snapshots__/index.test.tsx.snap @@ -0,0 +1,141 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`useSlippageConfig snapshots matches snapshot for EVM chain 1`] = ` +{ + "default_slippage_options": [ + "0.5", + "2", + "3", + ], + "has_custom_slippage_option": true, + "input_max_decimals": 2, + "input_step": 0.1, + "lower_allowed_slippage_threshold": { + "inclusive": true, + "messageId": "bridge.exceeding_lower_slippage_error", + "value": 0.1, + }, + "lower_suggested_slippage_threshold": { + "inclusive": false, + "messageId": "bridge.exceeding_lower_slippage_warning", + "value": 0.5, + }, + "max_amount": 100, + "min_amount": 0, + "upper_allowed_slippage_threshold": { + "inclusive": false, + "messageId": "bridge.exceeding_upper_slippage_error", + "value": 100, + }, + "upper_suggested_slippage_threshold": { + "inclusive": false, + "messageId": "bridge.exceeding_upper_slippage_warning", + "value": 5, + }, +} +`; + +exports[`useSlippageConfig snapshots matches snapshot for Solana-to-Solana 1`] = ` +{ + "default_slippage_options": [ + "auto", + "0.5", + "2", + ], + "has_custom_slippage_option": true, + "input_max_decimals": 2, + "input_step": 0.1, + "lower_allowed_slippage_threshold": { + "inclusive": true, + "messageId": "bridge.exceeding_lower_slippage_error", + "value": 0.1, + }, + "lower_suggested_slippage_threshold": { + "inclusive": false, + "messageId": "bridge.exceeding_lower_slippage_warning", + "value": 0.5, + }, + "max_amount": 100, + "min_amount": 0, + "upper_allowed_slippage_threshold": { + "inclusive": false, + "messageId": "bridge.exceeding_upper_slippage_error", + "value": 100, + }, + "upper_suggested_slippage_threshold": { + "inclusive": false, + "messageId": "bridge.exceeding_upper_slippage_warning", + "value": 5, + }, +} +`; + +exports[`useSlippageConfig snapshots matches snapshot for cross-chain (no specific config) 1`] = ` +{ + "default_slippage_options": [ + "0.5", + "2", + "3", + ], + "has_custom_slippage_option": true, + "input_max_decimals": 2, + "input_step": 0.1, + "lower_allowed_slippage_threshold": { + "inclusive": true, + "messageId": "bridge.exceeding_lower_slippage_error", + "value": 0.1, + }, + "lower_suggested_slippage_threshold": { + "inclusive": false, + "messageId": "bridge.exceeding_lower_slippage_warning", + "value": 0.5, + }, + "max_amount": 100, + "min_amount": 0, + "upper_allowed_slippage_threshold": { + "inclusive": false, + "messageId": "bridge.exceeding_upper_slippage_error", + "value": 100, + }, + "upper_suggested_slippage_threshold": { + "inclusive": false, + "messageId": "bridge.exceeding_upper_slippage_warning", + "value": 5, + }, +} +`; + +exports[`useSlippageConfig snapshots matches snapshot for empty options 1`] = ` +{ + "default_slippage_options": [ + "0.5", + "2", + "3", + ], + "has_custom_slippage_option": true, + "input_max_decimals": 2, + "input_step": 0.1, + "lower_allowed_slippage_threshold": { + "inclusive": true, + "messageId": "bridge.exceeding_lower_slippage_error", + "value": 0.1, + }, + "lower_suggested_slippage_threshold": { + "inclusive": false, + "messageId": "bridge.exceeding_lower_slippage_warning", + "value": 0.5, + }, + "max_amount": 100, + "min_amount": 0, + "upper_allowed_slippage_threshold": { + "inclusive": false, + "messageId": "bridge.exceeding_upper_slippage_error", + "value": 100, + }, + "upper_suggested_slippage_threshold": { + "inclusive": false, + "messageId": "bridge.exceeding_upper_slippage_warning", + "value": 5, + }, +} +`; diff --git a/app/components/UI/Bridge/hooks/useSlippageConfig/index.test.tsx b/app/components/UI/Bridge/hooks/useSlippageConfig/index.test.tsx new file mode 100644 index 00000000000..a0789f437bd --- /dev/null +++ b/app/components/UI/Bridge/hooks/useSlippageConfig/index.test.tsx @@ -0,0 +1,496 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useSlippageConfig } from './index'; +import AppConstants from '../../../../../core/AppConstants'; + +describe('useSlippageConfig', () => { + const defaultConfig = AppConstants.BRIDGE.SLIPPAGE_CONFIG.__default__; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('returns default config', () => { + it('when no params provided', () => { + const { result } = renderHook(() => useSlippageConfig({})); + + expect(result.current).toEqual(defaultConfig); + }); + + it('when sourceChainId is undefined', () => { + const { result } = renderHook(() => + useSlippageConfig({ sourceChainId: undefined }), + ); + + expect(result.current).toEqual(defaultConfig); + }); + + it('when sourceChainId has no matching config', () => { + const { result } = renderHook(() => + useSlippageConfig({ sourceChainId: 'eip155:1' }), + ); + + expect(result.current).toEqual(defaultConfig); + }); + + it('when sourceChainId and destChainId have no matching config', () => { + const { result } = renderHook(() => + useSlippageConfig({ + sourceChainId: 'eip155:1', + destChainId: 'eip155:137', + }), + ); + + expect(result.current).toEqual(defaultConfig); + }); + }); + + describe('chain ID format handling', () => { + it('handles hex chainId format for sourceChainId', () => { + const { result } = renderHook(() => + useSlippageConfig({ sourceChainId: '0x1' }), + ); + + expect(result.current).toEqual(defaultConfig); + }); + + it('handles hex chainId format for destChainId', () => { + const { result } = renderHook(() => + useSlippageConfig({ + sourceChainId: '0x1', + destChainId: '0x89', + }), + ); + + expect(result.current).toEqual(defaultConfig); + }); + + it('handles CAIP chainId format', () => { + const { result } = renderHook(() => + useSlippageConfig({ sourceChainId: 'eip155:1' }), + ); + + expect(result.current).toEqual(defaultConfig); + }); + + it('handles Solana CAIP format', () => { + const { result } = renderHook(() => + useSlippageConfig({ + sourceChainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + destChainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + }), + ); + + // Solana->Solana has specific config + expect(result.current.default_slippage_options).toEqual([ + 'auto', + '0.5', + '2', + ]); + }); + }); + + describe('config priority and merging', () => { + beforeEach(() => { + // Set up test configs with wildcard and destination-specific overrides + (AppConstants.BRIDGE.SLIPPAGE_CONFIG as Record)[ + 'eip155:100' + ] = { + '*': { + max_amount: 75, + input_step: 0.5, + }, + 'eip155:200': { + max_amount: 50, + default_slippage_options: ['1', '2', '3'], + }, + }; + }); + + afterEach(() => { + delete (AppConstants.BRIDGE.SLIPPAGE_CONFIG as Record)[ + 'eip155:100' + ]; + }); + + it('applies wildcard config when only sourceChainId matches', () => { + const { result } = renderHook(() => + useSlippageConfig({ sourceChainId: 'eip155:100' }), + ); + + // Wildcard config values + expect(result.current.max_amount).toBe(75); + expect(result.current.input_step).toBe(0.5); + // Default values preserved + expect(result.current.min_amount).toBe(defaultConfig.min_amount); + }); + + it('applies wildcard config when destChainId has no specific config', () => { + const { result } = renderHook(() => + useSlippageConfig({ + sourceChainId: 'eip155:100', + destChainId: 'eip155:999', // No specific config for this dest + }), + ); + + // Wildcard config values + expect(result.current.max_amount).toBe(75); + expect(result.current.input_step).toBe(0.5); + }); + + it('applies destination-specific config over wildcard', () => { + const { result } = renderHook(() => + useSlippageConfig({ + sourceChainId: 'eip155:100', + destChainId: 'eip155:200', + }), + ); + + // Destination-specific overrides wildcard + expect(result.current.max_amount).toBe(50); + expect(result.current.default_slippage_options).toEqual(['1', '2', '3']); + // Wildcard values preserved where not overridden + expect(result.current.input_step).toBe(0.5); + }); + + it('preserves default values not overridden by any config', () => { + const { result } = renderHook(() => + useSlippageConfig({ + sourceChainId: 'eip155:100', + destChainId: 'eip155:200', + }), + ); + + // These should come from default config + expect(result.current.min_amount).toBe(defaultConfig.min_amount); + expect(result.current.input_max_decimals).toBe( + defaultConfig.input_max_decimals, + ); + expect(result.current.has_custom_slippage_option).toBe( + defaultConfig.has_custom_slippage_option, + ); + }); + }); + + describe('Solana-specific configuration', () => { + const solanaChainId = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; + + it('returns default config for Solana source without dest', () => { + const { result } = renderHook(() => + useSlippageConfig({ sourceChainId: solanaChainId }), + ); + + // No wildcard config for Solana, so defaults apply + expect(result.current.default_slippage_options).toEqual( + defaultConfig.default_slippage_options, + ); + }); + + it('returns Solana-specific config for Solana-to-Solana', () => { + const { result } = renderHook(() => + useSlippageConfig({ + sourceChainId: solanaChainId, + destChainId: solanaChainId, + }), + ); + + expect(result.current.default_slippage_options).toEqual([ + 'auto', + '0.5', + '2', + ]); + }); + + it('has "auto" as first slippage option for Solana-to-Solana', () => { + const { result } = renderHook(() => + useSlippageConfig({ + sourceChainId: solanaChainId, + destChainId: solanaChainId, + }), + ); + + expect(result.current.default_slippage_options[0]).toBe('auto'); + }); + + it('preserves all other config values for Solana-to-Solana', () => { + const { result } = renderHook(() => + useSlippageConfig({ + sourceChainId: solanaChainId, + destChainId: solanaChainId, + }), + ); + + expect(result.current.input_step).toBe(defaultConfig.input_step); + expect(result.current.max_amount).toBe(defaultConfig.max_amount); + expect(result.current.min_amount).toBe(defaultConfig.min_amount); + expect(result.current.lower_allowed_slippage_threshold).toEqual( + defaultConfig.lower_allowed_slippage_threshold, + ); + }); + + it('returns default config for Solana to non-Solana chain', () => { + const { result } = renderHook(() => + useSlippageConfig({ + sourceChainId: solanaChainId, + destChainId: 'eip155:1', + }), + ); + + // No config for Solana->Ethereum, so defaults apply + expect(result.current.default_slippage_options).toEqual( + defaultConfig.default_slippage_options, + ); + }); + }); + + describe('deep merge behavior', () => { + beforeEach(() => { + (AppConstants.BRIDGE.SLIPPAGE_CONFIG as Record)[ + 'eip155:999' + ] = { + 'eip155:888': { + max_amount: 50, + lower_allowed_slippage_threshold: { + value: 1, + }, + }, + }; + }); + + afterEach(() => { + delete (AppConstants.BRIDGE.SLIPPAGE_CONFIG as Record)[ + 'eip155:999' + ]; + }); + + it('deep merges nested threshold objects', () => { + const { result } = renderHook(() => + useSlippageConfig({ + sourceChainId: 'eip155:999', + destChainId: 'eip155:888', + }), + ); + + expect(result.current.max_amount).toBe(50); + expect(result.current.lower_allowed_slippage_threshold?.value).toBe(1); + // Other threshold properties should be preserved from default + expect(result.current.lower_allowed_slippage_threshold?.messageId).toBe( + defaultConfig.lower_allowed_slippage_threshold?.messageId, + ); + expect(result.current.lower_allowed_slippage_threshold?.inclusive).toBe( + defaultConfig.lower_allowed_slippage_threshold?.inclusive, + ); + }); + }); + + describe('array replacement behavior', () => { + beforeEach(() => { + (AppConstants.BRIDGE.SLIPPAGE_CONFIG as Record)[ + 'eip155:996' + ] = { + 'eip155:995': { + default_slippage_options: ['10', '20'], + }, + }; + }); + + afterEach(() => { + delete (AppConstants.BRIDGE.SLIPPAGE_CONFIG as Record)[ + 'eip155:996' + ]; + }); + + it('replaces arrays rather than merging them', () => { + const { result } = renderHook(() => + useSlippageConfig({ + sourceChainId: 'eip155:996', + destChainId: 'eip155:995', + }), + ); + + // Array should be completely replaced, not merged + expect(result.current.default_slippage_options).toEqual(['10', '20']); + expect(result.current.default_slippage_options).toHaveLength(2); + }); + }); + + describe('null threshold override', () => { + beforeEach(() => { + (AppConstants.BRIDGE.SLIPPAGE_CONFIG as Record)[ + 'eip155:997' + ] = { + 'eip155:998': { + lower_suggested_slippage_threshold: null, + }, + }; + }); + + afterEach(() => { + delete (AppConstants.BRIDGE.SLIPPAGE_CONFIG as Record)[ + 'eip155:997' + ]; + }); + + it('handles null threshold override', () => { + const { result } = renderHook(() => + useSlippageConfig({ + sourceChainId: 'eip155:997', + destChainId: 'eip155:998', + }), + ); + + expect(result.current.lower_suggested_slippage_threshold).toBeNull(); + // Other thresholds remain intact + expect(result.current.upper_suggested_slippage_threshold).toEqual( + defaultConfig.upper_suggested_slippage_threshold, + ); + }); + }); + + describe('error handling', () => { + it('returns default config for invalid hex chainId', () => { + const { result } = renderHook(() => + useSlippageConfig({ sourceChainId: '0xGGG' as `0x${string}` }), + ); + + expect(result.current).toEqual(defaultConfig); + }); + + it('returns default config for malformed CAIP chainId', () => { + const { result } = renderHook(() => + useSlippageConfig({ + sourceChainId: 'invalid:format:chain' as `${string}:${string}`, + }), + ); + + expect(result.current).toEqual(defaultConfig); + }); + + it('returns default config for non-numeric hex chainId', () => { + const { result } = renderHook(() => + useSlippageConfig({ sourceChainId: '0xZZZ' as `0x${string}` }), + ); + + expect(result.current).toEqual(defaultConfig); + }); + + it('returns default config for empty string sourceChainId', () => { + const { result } = renderHook(() => + useSlippageConfig({ sourceChainId: '' as `0x${string}` }), + ); + + expect(result.current).toEqual(defaultConfig); + }); + + it('returns default config when destChainId is invalid but sourceChainId is valid', () => { + const { result } = renderHook(() => + useSlippageConfig({ + sourceChainId: 'eip155:1', + destChainId: 'invalid:chain:id' as `${string}:${string}`, + }), + ); + + expect(result.current).toEqual(defaultConfig); + }); + }); + + describe('config immutability', () => { + it('does not mutate default config', () => { + const originalDefault = JSON.parse(JSON.stringify(defaultConfig)); + + renderHook(() => + useSlippageConfig({ + sourceChainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + destChainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + }), + ); + + expect(defaultConfig).toEqual(originalDefault); + }); + }); + + describe('config structure validation', () => { + it('returns config with all required fields', () => { + const { result } = renderHook(() => + useSlippageConfig({ sourceChainId: '0x1' }), + ); + + expect(result.current).toHaveProperty('input_step'); + expect(result.current).toHaveProperty('max_amount'); + expect(result.current).toHaveProperty('min_amount'); + expect(result.current).toHaveProperty('input_max_decimals'); + expect(result.current).toHaveProperty('default_slippage_options'); + expect(result.current).toHaveProperty('has_custom_slippage_option'); + }); + + it('returns config with correct types', () => { + const { result } = renderHook(() => + useSlippageConfig({ sourceChainId: '0x1' }), + ); + + expect(typeof result.current.input_step).toBe('number'); + expect(typeof result.current.max_amount).toBe('number'); + expect(typeof result.current.min_amount).toBe('number'); + expect(typeof result.current.input_max_decimals).toBe('number'); + expect(Array.isArray(result.current.default_slippage_options)).toBe(true); + expect(typeof result.current.has_custom_slippage_option).toBe('boolean'); + }); + }); + + describe('memoization', () => { + it('returns consistent values for same inputs', () => { + const { result: result1 } = renderHook(() => + useSlippageConfig({ sourceChainId: '0x1' }), + ); + const { result: result2 } = renderHook(() => + useSlippageConfig({ sourceChainId: '0x1' }), + ); + + expect(result1.current).toEqual(result2.current); + }); + + it('returns consistent values for equivalent chain formats', () => { + const { result: hexResult } = renderHook(() => + useSlippageConfig({ sourceChainId: '0x1' }), + ); + const { result: caipResult } = renderHook(() => + useSlippageConfig({ sourceChainId: 'eip155:1' }), + ); + + expect(hexResult.current).toEqual(caipResult.current); + }); + }); + + describe('snapshots', () => { + it('matches snapshot for empty options', () => { + const { result } = renderHook(() => useSlippageConfig({})); + expect(result.current).toMatchSnapshot(); + }); + + it('matches snapshot for EVM chain', () => { + const { result } = renderHook(() => + useSlippageConfig({ sourceChainId: '0x1' }), + ); + expect(result.current).toMatchSnapshot(); + }); + + it('matches snapshot for Solana-to-Solana', () => { + const { result } = renderHook(() => + useSlippageConfig({ + sourceChainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + destChainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + }), + ); + expect(result.current).toMatchSnapshot(); + }); + + it('matches snapshot for cross-chain (no specific config)', () => { + const { result } = renderHook(() => + useSlippageConfig({ + sourceChainId: 'eip155:1', + destChainId: 'eip155:137', + }), + ); + expect(result.current).toMatchSnapshot(); + }); + }); +}); diff --git a/app/components/UI/Bridge/hooks/useSlippageConfig/index.ts b/app/components/UI/Bridge/hooks/useSlippageConfig/index.ts new file mode 100644 index 00000000000..f945f84cbea --- /dev/null +++ b/app/components/UI/Bridge/hooks/useSlippageConfig/index.ts @@ -0,0 +1,101 @@ +import { mergeWith, getOr } from 'lodash/fp'; +import AppConstants from '../../../../../core/AppConstants'; +import { CaipChainId, Hex } from '@metamask/utils'; +import { formatChainIdToCaip } from '@metamask/bridge-controller'; +import { BridgeSlippageConfig } from '../../types'; +import { useMemo } from 'react'; + +interface UseSlippageConfigOptions { + sourceChainId?: CaipChainId | Hex; + destChainId?: CaipChainId | Hex; +} + +type SlippageConfig = BridgeSlippageConfig['__default__']; +type PartialSlippageConfig = Partial; + +/** + * When merging configs, arrays should be replaced entirely (not merged by index). + */ +const mergeCustomizer = (_objValue: unknown, srcValue: unknown) => { + if (Array.isArray(srcValue)) { + return srcValue; + } + return undefined; +}; + +/** + * Merges base config with overrides in order. + * Later overrides take precedence over earlier ones. + */ +const mergeConfigs = ( + base: SlippageConfig, + overrides: PartialSlippageConfig[], +): SlippageConfig => { + let result = base; + + for (const override of overrides) { + result = mergeWith(mergeCustomizer, result, override); + } + + return result; +}; + +/** + * Hook to get slippage configuration based on source and destination chains. + * + * Config priority (lowest to highest): + * 1. `__default__` - base configuration for all chains + * 2. `[sourceChainId]['*']` - wildcard override when source chain matches + * 3. `[sourceChainId][destChainId]` - specific source→destination override + * + * @example + * // Get default config + * useSlippageConfig({}) + * + * // Get config for Solana source + * useSlippageConfig({ sourceChainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' }) + * + * // Get config for Solana→Solana swap + * useSlippageConfig({ + * sourceChainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + * destChainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' + * }) + */ +export const useSlippageConfig = ({ + sourceChainId, + destChainId, +}: UseSlippageConfigOptions): SlippageConfig => { + const defaultConfig = AppConstants.BRIDGE.SLIPPAGE_CONFIG.__default__; + + return useMemo(() => { + // No source chain = use defaults + if (!sourceChainId) { + return defaultConfig; + } + + try { + const sourceCaip = formatChainIdToCaip(sourceChainId); + const slippageConfig = AppConstants.BRIDGE.SLIPPAGE_CONFIG; + + // Get wildcard config: SLIPPAGE_CONFIG[sourceChain]['*'] + const wildcardConfig = getOr( + {}, + [sourceCaip, '*'], + slippageConfig, + ) as PartialSlippageConfig; + + // Get destination-specific config: SLIPPAGE_CONFIG[sourceChain][destChain] + let destConfig: PartialSlippageConfig = {}; + if (destChainId) { + const destCaip = formatChainIdToCaip(destChainId); + destConfig = getOr({}, [sourceCaip, destCaip], slippageConfig); + } + + // Merge in priority order: default < wildcard < destination + return mergeConfigs(defaultConfig, [wildcardConfig, destConfig]); + } catch { + // Invalid chain ID format - return defaults + return defaultConfig; + } + }, [defaultConfig, sourceChainId, destChainId]); +}; diff --git a/app/components/UI/Bridge/hooks/useSlippageStepperDescription/__snapshots__/index.test.tsx.snap b/app/components/UI/Bridge/hooks/useSlippageStepperDescription/__snapshots__/index.test.tsx.snap new file mode 100644 index 00000000000..a475ce3b3f5 --- /dev/null +++ b/app/components/UI/Bridge/hooks/useSlippageStepperDescription/__snapshots__/index.test.tsx.snap @@ -0,0 +1,147 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`useSlippageStepperDescription complete snapshots for all states snapshot for lower allowed error 1`] = ` +{ + "color": "text-error-default", + "icon": { + "color": "text-error-default", + "name": "Danger", + "size": "24", + }, + "message": "bridge.lower_allowed_error [0.1]", +} +`; + +exports[`useSlippageStepperDescription complete snapshots for all states snapshot for lower suggested warning 1`] = ` +{ + "color": "text-warning-default", + "icon": { + "color": "text-warning-default", + "name": "Danger", + "size": "24", + }, + "message": "bridge.lower_suggested_warning [0.5]", +} +`; + +exports[`useSlippageStepperDescription complete snapshots for all states snapshot for no violation 1`] = `undefined`; + +exports[`useSlippageStepperDescription complete snapshots for all states snapshot for upper allowed error 1`] = ` +{ + "color": "text-error-default", + "icon": { + "color": "text-error-default", + "name": "Danger", + "size": "24", + }, + "message": "bridge.upper_allowed_error [50]", +} +`; + +exports[`useSlippageStepperDescription complete snapshots for all states snapshot for upper suggested warning 1`] = ` +{ + "color": "text-warning-default", + "icon": { + "color": "text-warning-default", + "name": "Danger", + "size": "24", + }, + "message": "bridge.upper_suggested_warning [5]", +} +`; + +exports[`useSlippageStepperDescription complete snapshots for all states snapshot with hasAttemptedToExceedMax 1`] = ` +{ + "color": "text-error-default", + "icon": { + "color": "text-error-default", + "name": "Danger", + "size": "24", + }, + "message": "bridge.upper_allowed_error [50]", +} +`; + +exports[`useSlippageStepperDescription lower_allowed_slippage_threshold (ERROR) returns error when value is below inclusive lower allowed threshold 1`] = ` +{ + "color": "text-error-default", + "icon": { + "color": "text-error-default", + "name": "Danger", + "size": "24", + }, + "message": "bridge.lower_allowed_error [0.1]", +} +`; + +exports[`useSlippageStepperDescription lower_allowed_slippage_threshold (ERROR) returns error when value violates inclusive lower allowed threshold 1`] = ` +{ + "color": "text-error-default", + "icon": { + "color": "text-error-default", + "name": "Danger", + "size": "24", + }, + "message": "bridge.lower_allowed_error [0.1]", +} +`; + +exports[`useSlippageStepperDescription lower_suggested_slippage_threshold (WARNING) returns warning when value violates exclusive lower suggested threshold 1`] = ` +{ + "color": "text-warning-default", + "icon": { + "color": "text-warning-default", + "name": "Danger", + "size": "24", + }, + "message": "bridge.lower_suggested_warning [0.5]", +} +`; + +exports[`useSlippageStepperDescription upper_allowed_slippage_threshold (ERROR) returns error when value exceeds inclusive upper allowed threshold 1`] = ` +{ + "color": "text-error-default", + "icon": { + "color": "text-error-default", + "name": "Danger", + "size": "24", + }, + "message": "bridge.upper_allowed_error [50]", +} +`; + +exports[`useSlippageStepperDescription upper_allowed_slippage_threshold (ERROR) returns error when value violates inclusive upper allowed threshold 1`] = ` +{ + "color": "text-error-default", + "icon": { + "color": "text-error-default", + "name": "Danger", + "size": "24", + }, + "message": "bridge.upper_allowed_error [50]", +} +`; + +exports[`useSlippageStepperDescription upper_allowed_slippage_threshold (ERROR) triggers error with hasAttemptedToExceedMax flag 1`] = ` +{ + "color": "text-error-default", + "icon": { + "color": "text-error-default", + "name": "Danger", + "size": "24", + }, + "message": "bridge.upper_allowed_error [50]", +} +`; + +exports[`useSlippageStepperDescription upper_suggested_slippage_threshold (WARNING) returns warning when value exceeds exclusive upper suggested threshold 1`] = ` +{ + "color": "text-warning-default", + "icon": { + "color": "text-warning-default", + "name": "Danger", + "size": "24", + }, + "message": "bridge.upper_suggested_warning [5]", +} +`; diff --git a/app/components/UI/Bridge/hooks/useSlippageStepperDescription/index.test.tsx b/app/components/UI/Bridge/hooks/useSlippageStepperDescription/index.test.tsx new file mode 100644 index 00000000000..5a003a965f1 --- /dev/null +++ b/app/components/UI/Bridge/hooks/useSlippageStepperDescription/index.test.tsx @@ -0,0 +1,634 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useSlippageStepperDescription } from './index'; +import { BridgeSlippageConfig } from '../../types'; +import { + IconColor, + IconName, + IconSize, + TextColor, +} from '@metamask/design-system-react-native'; + +// Mock i18n +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string, options?: { value?: number }) => { + if (options?.value !== undefined) { + return `${key} [${options.value}]`; + } + return key; + }), +})); + +describe('useSlippageStepperDescription', () => { + const defaultSlippageConfig: BridgeSlippageConfig['__default__'] = { + input_step: 0.1, + max_amount: 100, + min_amount: 0, + input_max_decimals: 2, + lower_allowed_slippage_threshold: { + messageId: 'bridge.lower_allowed_error', + value: 0.1, + inclusive: true, + }, + lower_suggested_slippage_threshold: { + messageId: 'bridge.lower_suggested_warning', + value: 0.5, + inclusive: false, + }, + upper_suggested_slippage_threshold: { + messageId: 'bridge.upper_suggested_warning', + value: 5, + inclusive: false, + }, + upper_allowed_slippage_threshold: { + messageId: 'bridge.upper_allowed_error', + value: 50, + inclusive: true, + }, + default_slippage_options: ['0.5', '2', '3'], + has_custom_slippage_option: true, + }; + + describe('returns undefined when no threshold violated', () => { + it('returns undefined for valid value in range', () => { + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '2', + slippageConfig: defaultSlippageConfig, + hasAttemptedToExceedMax: false, + }), + ); + + expect(result.current).toBeUndefined(); + }); + + it('returns undefined when value is in safe zone', () => { + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '3', + slippageConfig: defaultSlippageConfig, + hasAttemptedToExceedMax: false, + }), + ); + + expect(result.current).toBeUndefined(); + }); + }); + + describe('lower_allowed_slippage_threshold (ERROR)', () => { + it('returns error when value violates inclusive lower allowed threshold', () => { + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '0.1', + slippageConfig: defaultSlippageConfig, + hasAttemptedToExceedMax: false, + }), + ); + + expect(result.current?.color).toBe(TextColor.ErrorDefault); + expect(result.current?.message).toBe('bridge.lower_allowed_error [0.1]'); + expect(result.current).toMatchSnapshot(); + }); + + it('returns error when value is below inclusive lower allowed threshold', () => { + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '0.05', + slippageConfig: defaultSlippageConfig, + hasAttemptedToExceedMax: false, + }), + ); + + expect(result.current).toMatchSnapshot(); + }); + + it('handles exclusive lower allowed threshold', () => { + const config = { + ...defaultSlippageConfig, + lower_allowed_slippage_threshold: { + messageId: 'bridge.lower_allowed_error', + value: 0.5, + inclusive: false, + }, + }; + + // At threshold with exclusive should NOT trigger error + const { result: atThreshold } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '0.5', + slippageConfig: config, + hasAttemptedToExceedMax: false, + }), + ); + expect(atThreshold.current).toBeUndefined(); + }); + }); + + describe('lower_suggested_slippage_threshold (WARNING)', () => { + it('returns warning when value violates exclusive lower suggested threshold', () => { + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '0.4', + slippageConfig: defaultSlippageConfig, + hasAttemptedToExceedMax: false, + }), + ); + + expect(result.current?.color).toBe(TextColor.WarningDefault); + expect(result.current?.message).toBe( + 'bridge.lower_suggested_warning [0.5]', + ); + expect(result.current).toMatchSnapshot(); + }); + + it('does not trigger at threshold value with exclusive', () => { + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '0.5', + slippageConfig: defaultSlippageConfig, + hasAttemptedToExceedMax: false, + }), + ); + + // At 0.5 with exclusive should not trigger + expect(result.current).toBeUndefined(); + }); + }); + + describe('upper_suggested_slippage_threshold (WARNING)', () => { + it('returns warning when value exceeds exclusive upper suggested threshold', () => { + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '6', + slippageConfig: defaultSlippageConfig, + hasAttemptedToExceedMax: false, + }), + ); + + expect(result.current?.color).toBe(TextColor.WarningDefault); + expect(result.current?.message).toBe( + 'bridge.upper_suggested_warning [5]', + ); + expect(result.current).toMatchSnapshot(); + }); + + it('does not trigger at threshold value with exclusive', () => { + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '5', + slippageConfig: defaultSlippageConfig, + hasAttemptedToExceedMax: false, + }), + ); + + // At 5 with exclusive should not trigger + expect(result.current).toBeUndefined(); + }); + }); + + describe('upper_allowed_slippage_threshold (ERROR)', () => { + it('returns error when value violates inclusive upper allowed threshold', () => { + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '50', + slippageConfig: defaultSlippageConfig, + hasAttemptedToExceedMax: false, + }), + ); + + expect(result.current?.color).toBe(TextColor.ErrorDefault); + expect(result.current?.message).toBe('bridge.upper_allowed_error [50]'); + expect(result.current).toMatchSnapshot(); + }); + + it('returns error when value exceeds inclusive upper allowed threshold', () => { + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '51', + slippageConfig: defaultSlippageConfig, + hasAttemptedToExceedMax: false, + }), + ); + + expect(result.current).toMatchSnapshot(); + }); + + it('triggers error with hasAttemptedToExceedMax flag', () => { + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '10', + slippageConfig: defaultSlippageConfig, + hasAttemptedToExceedMax: true, + }), + ); + + // Even though value is valid, hasAttemptedToExceedMax should trigger error + expect(result.current).toMatchSnapshot(); + }); + }); + + describe('threshold priority order', () => { + it('prioritizes lower allowed error over lower suggested warning', () => { + // 0.09 violates both lower_allowed (0.1) and lower_suggested (0.5) + // Should return ERROR, not WARNING + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '0.09', + slippageConfig: defaultSlippageConfig, + hasAttemptedToExceedMax: false, + }), + ); + + expect(result.current?.message).toBe('bridge.lower_allowed_error [0.1]'); + }); + + it('shows warning when only suggested threshold violated', () => { + // 0.2 is above lower_allowed (0.1) but below lower_suggested (0.5) + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '0.2', + slippageConfig: defaultSlippageConfig, + hasAttemptedToExceedMax: false, + }), + ); + + expect(result.current?.message).toBe( + 'bridge.lower_suggested_warning [0.5]', + ); + }); + + it('prioritizes upper allowed error over upper suggested warning', () => { + // 60 violates both upper_suggested (5) and upper_allowed (50) + // Should return ERROR, not WARNING + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '60', + slippageConfig: defaultSlippageConfig, + hasAttemptedToExceedMax: false, + }), + ); + + expect(result.current?.message).toBe('bridge.upper_allowed_error [50]'); + }); + }); + + describe('icon configuration', () => { + it('includes icon with correct properties for ERROR', () => { + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '0.05', + slippageConfig: defaultSlippageConfig, + hasAttemptedToExceedMax: false, + }), + ); + + expect(result.current?.icon).toEqual({ + name: IconName.Danger, + size: IconSize.Lg, + color: IconColor.ErrorDefault, + }); + }); + + it('includes icon with correct properties for WARNING', () => { + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '0.3', + slippageConfig: defaultSlippageConfig, + hasAttemptedToExceedMax: false, + }), + ); + + expect(result.current?.icon).toEqual({ + name: IconName.Danger, + size: IconSize.Lg, + color: IconColor.WarningDefault, + }); + }); + }); + + describe('null threshold handling', () => { + it('works when all thresholds are null', () => { + const config: BridgeSlippageConfig['__default__'] = { + ...defaultSlippageConfig, + lower_allowed_slippage_threshold: null, + lower_suggested_slippage_threshold: null, + upper_suggested_slippage_threshold: null, + upper_allowed_slippage_threshold: null, + }; + + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '0.01', + slippageConfig: config, + hasAttemptedToExceedMax: false, + }), + ); + + expect(result.current).toBeUndefined(); + }); + + it('skips null thresholds and checks next one', () => { + const config: BridgeSlippageConfig['__default__'] = { + ...defaultSlippageConfig, + lower_allowed_slippage_threshold: null, + lower_suggested_slippage_threshold: { + messageId: 'bridge.lower_suggested_warning', + value: 0.5, + inclusive: false, + }, + }; + + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '0.3', + slippageConfig: config, + hasAttemptedToExceedMax: false, + }), + ); + + // Should skip null lower_allowed and trigger lower_suggested + expect(result.current?.message).toBe( + 'bridge.lower_suggested_warning [0.5]', + ); + }); + }); + + describe('hasAttemptedToExceedMax flag', () => { + it('triggers upper allowed error when flag is true', () => { + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '10', + slippageConfig: defaultSlippageConfig, + hasAttemptedToExceedMax: true, + }), + ); + + expect(result.current?.message).toBe('bridge.upper_allowed_error [50]'); + }); + + it('does not trigger when flag is false and value is in safe range', () => { + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '5', + slippageConfig: defaultSlippageConfig, + hasAttemptedToExceedMax: false, + }), + ); + + // Value 3 is in safe zone (above 0.5, below 5), should not trigger anything + expect(result.current).toBeUndefined(); + }); + + it('flag works even at threshold boundary', () => { + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '50', + slippageConfig: defaultSlippageConfig, + hasAttemptedToExceedMax: true, + }), + ); + + expect(result.current).not.toBeUndefined(); + }); + }); + + describe('edge cases', () => { + it('handles empty string input', () => { + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '', + slippageConfig: defaultSlippageConfig, + hasAttemptedToExceedMax: false, + }), + ); + + // parseFloat('') returns NaN, which fails all comparisons + expect(result.current).toBeUndefined(); + }); + + it('handles zero value', () => { + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '0', + slippageConfig: defaultSlippageConfig, + hasAttemptedToExceedMax: false, + }), + ); + + // 0 <= 0.1 (inclusive) triggers error + expect(result.current).not.toBeUndefined(); + }); + + it('handles decimal values', () => { + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '2.75', + slippageConfig: defaultSlippageConfig, + hasAttemptedToExceedMax: false, + }), + ); + + expect(result.current).toBeUndefined(); + }); + + it('handles very large values', () => { + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '999', + slippageConfig: defaultSlippageConfig, + hasAttemptedToExceedMax: false, + }), + ); + + expect(result.current?.message).toBe('bridge.upper_allowed_error [50]'); + }); + + it('handles very small decimal values', () => { + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '0.001', + slippageConfig: defaultSlippageConfig, + hasAttemptedToExceedMax: false, + }), + ); + + expect(result.current).not.toBeUndefined(); + }); + + it('handles invalid numeric input', () => { + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: 'abc', + slippageConfig: defaultSlippageConfig, + hasAttemptedToExceedMax: false, + }), + ); + + // parseFloat('abc') returns NaN + expect(result.current).toBeUndefined(); + }); + }); + + describe('inclusive vs exclusive logic', () => { + it('inclusive lower threshold triggers at exact value', () => { + const config = { + ...defaultSlippageConfig, + lower_suggested_slippage_threshold: null, // Disable to isolate test + lower_allowed_slippage_threshold: { + messageId: 'bridge.error', + value: 1, + inclusive: true, + }, + }; + + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '1', + slippageConfig: config, + hasAttemptedToExceedMax: false, + }), + ); + + expect(result.current).not.toBeUndefined(); + }); + + it('exclusive lower threshold does not trigger at exact value', () => { + const config = { + ...defaultSlippageConfig, + lower_suggested_slippage_threshold: null, // Disable to isolate test + lower_allowed_slippage_threshold: { + messageId: 'bridge.error', + value: 1, + inclusive: false, + }, + }; + + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '1', + slippageConfig: config, + hasAttemptedToExceedMax: false, + }), + ); + + expect(result.current).toBeUndefined(); + }); + + it('inclusive upper threshold triggers at exact value', () => { + const config = { + ...defaultSlippageConfig, + upper_suggested_slippage_threshold: null, // Disable to isolate test + upper_allowed_slippage_threshold: { + messageId: 'bridge.error', + value: 10, + inclusive: true, + }, + }; + + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '10', + slippageConfig: config, + hasAttemptedToExceedMax: false, + }), + ); + + expect(result.current).not.toBeUndefined(); + }); + + it('exclusive upper threshold does not trigger at exact value', () => { + const config = { + ...defaultSlippageConfig, + upper_suggested_slippage_threshold: null, // Disable to isolate test + upper_allowed_slippage_threshold: { + messageId: 'bridge.error', + value: 10, + inclusive: false, + }, + }; + + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '10', + slippageConfig: config, + hasAttemptedToExceedMax: false, + }), + ); + + expect(result.current).toBeUndefined(); + }); + }); + + describe('complete snapshots for all states', () => { + it('snapshot for lower allowed error', () => { + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '0.05', + slippageConfig: defaultSlippageConfig, + hasAttemptedToExceedMax: false, + }), + ); + + expect(result.current).toMatchSnapshot(); + }); + + it('snapshot for lower suggested warning', () => { + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '0.3', + slippageConfig: defaultSlippageConfig, + hasAttemptedToExceedMax: false, + }), + ); + + expect(result.current).toMatchSnapshot(); + }); + + it('snapshot for upper suggested warning', () => { + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '10', + slippageConfig: defaultSlippageConfig, + hasAttemptedToExceedMax: false, + }), + ); + + expect(result.current).toMatchSnapshot(); + }); + + it('snapshot for upper allowed error', () => { + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '60', + slippageConfig: defaultSlippageConfig, + hasAttemptedToExceedMax: false, + }), + ); + + expect(result.current).toMatchSnapshot(); + }); + + it('snapshot for no violation', () => { + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '2', + slippageConfig: defaultSlippageConfig, + hasAttemptedToExceedMax: false, + }), + ); + + expect(result.current).toMatchSnapshot(); + }); + + it('snapshot with hasAttemptedToExceedMax', () => { + const { result } = renderHook(() => + useSlippageStepperDescription({ + inputAmount: '5', + slippageConfig: defaultSlippageConfig, + hasAttemptedToExceedMax: true, + }), + ); + + expect(result.current).toMatchSnapshot(); + }); + }); +}); diff --git a/app/components/UI/Bridge/hooks/useSlippageStepperDescription/index.ts b/app/components/UI/Bridge/hooks/useSlippageStepperDescription/index.ts new file mode 100644 index 00000000000..ec0c7765dca --- /dev/null +++ b/app/components/UI/Bridge/hooks/useSlippageStepperDescription/index.ts @@ -0,0 +1,74 @@ +import { useMemo } from 'react'; +import { strings } from '../../../../../../locales/i18n'; +import { BridgeSlippageConfig } from '../../types'; +import { InputStepperDescriptionType } from '../../components/InputStepper/constants'; +import { + IconColor, + IconName, + IconSize, + TextColor, +} from '@metamask/design-system-react-native'; +import { InputStepperProps } from '../../components/InputStepper/types'; + +interface Props { + inputAmount: string; + slippageConfig: BridgeSlippageConfig['__default__']; + hasAttemptedToExceedMax: boolean; +} + +export const useSlippageStepperDescription = ({ + inputAmount, + slippageConfig, + hasAttemptedToExceedMax, +}: Props): InputStepperProps['description'] => + useMemo(() => { + const value = parseFloat(inputAmount); + + // Note that order matters to render the correct messages. + const thresholds = [ + { + threshold: slippageConfig.lower_allowed_slippage_threshold, + type: InputStepperDescriptionType.ERROR, + compare: (v: number, t: number, inclusive: boolean) => + inclusive ? v <= t : v < t, + }, + { + threshold: slippageConfig.lower_suggested_slippage_threshold, + type: InputStepperDescriptionType.WARNING, + compare: (v: number, t: number, inclusive: boolean) => + inclusive ? v <= t : v < t, + }, + { + threshold: slippageConfig.upper_allowed_slippage_threshold, + type: InputStepperDescriptionType.ERROR, + compare: (v: number, t: number, inclusive: boolean) => + hasAttemptedToExceedMax || (inclusive ? v >= t : v > t), + }, + { + threshold: slippageConfig.upper_suggested_slippage_threshold, + type: InputStepperDescriptionType.WARNING, + compare: (v: number, t: number, inclusive: boolean) => + inclusive ? v >= t : v > t, + }, + ] as const; + + for (const { threshold, type, compare } of thresholds) { + if (threshold && compare(value, threshold.value, threshold.inclusive)) { + return { + color: + type === InputStepperDescriptionType.WARNING + ? TextColor.WarningDefault + : TextColor.ErrorDefault, + icon: { + name: IconName.Danger, + size: IconSize.Lg, + color: + type === InputStepperDescriptionType.WARNING + ? IconColor.WarningDefault + : IconColor.ErrorDefault, + }, + message: strings(threshold.messageId, { value: threshold.value }), + }; + } + } + }, [inputAmount, slippageConfig, hasAttemptedToExceedMax]); diff --git a/app/components/UI/Bridge/routes.tsx b/app/components/UI/Bridge/routes.tsx index 08ee586f394..8bec8afa220 100644 --- a/app/components/UI/Bridge/routes.tsx +++ b/app/components/UI/Bridge/routes.tsx @@ -2,12 +2,13 @@ import React from 'react'; import { createStackNavigator } from '@react-navigation/stack'; import Routes from '../../../constants/navigation/Routes'; import { BridgeTokenSelector } from './components/BridgeTokenSelector'; -import SlippageModal from './components/SlippageModal'; import BridgeView from './Views/BridgeView'; import BlockExplorersModal from './components/TransactionDetails/BlockExplorersModal'; import QuoteExpiredModal from './components/QuoteExpiredModal'; import BlockaidModal from './components/BlockaidModal'; import RecipientSelectorModal from './components/RecipientSelectorModal'; +import { DefaultSlippageModal } from './components/SlippageModal/DefaultSlippageModal'; +import { CustomSlippageModal } from './components/SlippageModal/CustomSlippageModal'; const clearStackNavigatorOptions = { headerShown: false, @@ -45,8 +46,12 @@ export const BridgeModalStack = () => ( screenOptions={clearStackNavigatorOptions} > + ; + + /** + * Override __default__ options when chain id match source token chain id and destination chain id. + * If a destination chain and wildcard config is not defined, the default values will be used. + * This has higher priority than __default__ and whildcard. + */ + [destinationChainId: CaipChainId]: Partial< + BridgeSlippageConfig['__default__'] + >; + }; +} + export enum TokenSelectorType { Source = 'source', Dest = 'dest', diff --git a/app/components/UI/Bridge/utils/calculateInputFontSize.test.ts b/app/components/UI/Bridge/utils/calculateInputFontSize.test.ts new file mode 100644 index 00000000000..307eae1a3e7 --- /dev/null +++ b/app/components/UI/Bridge/utils/calculateInputFontSize.test.ts @@ -0,0 +1,28 @@ +import { calculateInputFontSize } from './calculateInputFontSize'; + +describe('calculateInputFontSize', () => { + it('returns 40 for lengths up to 10', () => { + expect(calculateInputFontSize(5)).toBe(40); + expect(calculateInputFontSize(10)).toBe(40); + }); + + it('returns 35 for lengths between 11 and 15', () => { + expect(calculateInputFontSize(11)).toBe(35); + expect(calculateInputFontSize(15)).toBe(35); + }); + + it('returns 30 for lengths between 16 and 20', () => { + expect(calculateInputFontSize(16)).toBe(30); + expect(calculateInputFontSize(20)).toBe(30); + }); + + it('returns 25 for lengths between 21 and 25', () => { + expect(calculateInputFontSize(21)).toBe(25); + expect(calculateInputFontSize(25)).toBe(25); + }); + + it('returns 20 for lengths greater than 25', () => { + expect(calculateInputFontSize(26)).toBe(20); + expect(calculateInputFontSize(100)).toBe(20); + }); +}); diff --git a/app/components/UI/Bridge/utils/calculateInputFontSize.ts b/app/components/UI/Bridge/utils/calculateInputFontSize.ts new file mode 100644 index 00000000000..e7c5597586d --- /dev/null +++ b/app/components/UI/Bridge/utils/calculateInputFontSize.ts @@ -0,0 +1,7 @@ +export const calculateInputFontSize = (length: number): number => { + if (length <= 10) return 40; + if (length <= 15) return 35; + if (length <= 20) return 30; + if (length <= 25) return 25; + return 20; +}; diff --git a/app/components/UI/Bridge/utils/formatAmountWithLocaleSeparators.test.ts b/app/components/UI/Bridge/utils/formatAmountWithLocaleSeparators.test.ts new file mode 100644 index 00000000000..f4e88092116 --- /dev/null +++ b/app/components/UI/Bridge/utils/formatAmountWithLocaleSeparators.test.ts @@ -0,0 +1,291 @@ +import { formatAmountWithLocaleSeparators } from './formatAmountWithLocaleSeparators'; +import { getIntlNumberFormatter } from '../../../../util/intl'; + +jest.mock('../../../../util/intl', () => ({ + getIntlNumberFormatter: jest.fn(), +})); + +const mockGetIntlNumberFormatter = + getIntlNumberFormatter as jest.MockedFunction; + +describe('formatAmountWithLocaleSeparators', () => { + beforeEach(() => { + // Mock default en number formatter + mockGetIntlNumberFormatter.mockReturnValue({ + format: (value: number) => { + const parts = value.toString().split('.'); + const integerPart = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ','); + return parts[1] !== undefined + ? `${integerPart}.${parts[1]}` + : integerPart; + }, + } as Intl.NumberFormat); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('basic functionality', () => { + it('returns empty string as-is', () => { + const result = formatAmountWithLocaleSeparators(''); + expect(result).toBe(''); + }); + + it('returns zero as-is', () => { + const result = formatAmountWithLocaleSeparators('0'); + expect(result).toBe('0'); + }); + + it('formats whole numbers with thousands separator', () => { + const result = formatAmountWithLocaleSeparators('1000'); + expect(result).toBe('1,000'); + }); + + it('formats large numbers with multiple thousand separators', () => { + const result = formatAmountWithLocaleSeparators('1234567'); + expect(result).toBe('1,234,567'); + }); + + it('formats decimal numbers', () => { + const result = formatAmountWithLocaleSeparators('1234.56'); + expect(result).toBe('1,234.56'); + }); + + it('preserves single decimal place', () => { + const result = formatAmountWithLocaleSeparators('100.5'); + expect(result).toBe('100.5'); + }); + + it('preserves multiple decimal places', () => { + const result = formatAmountWithLocaleSeparators('1000.123456'); + expect(result).toBe('1,000.123456'); + }); + + it('formats small numbers without thousands separator', () => { + const result = formatAmountWithLocaleSeparators('123'); + expect(result).toBe('123'); + }); + }); + + describe('decimal preservation', () => { + it('preserves zero decimal places for whole numbers', () => { + const result = formatAmountWithLocaleSeparators('5000'); + + expect(mockGetIntlNumberFormatter).toHaveBeenCalledWith('en', { + useGrouping: true, + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }); + expect(result).toBe('5,000'); + }); + + it('preserves one decimal place', () => { + const result = formatAmountWithLocaleSeparators('100.50'); + + expect(mockGetIntlNumberFormatter).toHaveBeenCalledWith('en', { + useGrouping: true, + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + expect(result).toBe('100.5'); + }); + + it('preserves six decimal places', () => { + const result = formatAmountWithLocaleSeparators('1.123456'); + + expect(mockGetIntlNumberFormatter).toHaveBeenCalledWith('en', { + useGrouping: true, + minimumFractionDigits: 6, + maximumFractionDigits: 6, + }); + expect(result).toBe('1.123456'); + }); + + it('handles trailing zeros in decimals', () => { + const result = formatAmountWithLocaleSeparators('10.00'); + + expect(mockGetIntlNumberFormatter).toHaveBeenCalledWith('en', { + useGrouping: true, + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + expect(result).toBe('10'); + }); + }); + + describe('edge cases', () => { + it('handles invalid numeric strings', () => { + const result = formatAmountWithLocaleSeparators('abc'); + expect(result).toBe('abc'); + }); + + it('handles NaN', () => { + const result = formatAmountWithLocaleSeparators('NaN'); + expect(result).toBe('NaN'); + }); + + it('handles very small decimals', () => { + const result = formatAmountWithLocaleSeparators('0.00001'); + expect(result).toBe('0.00001'); + }); + + it('handles very large numbers', () => { + const result = formatAmountWithLocaleSeparators('999999999999'); + expect(result).toBe('999,999,999,999'); + }); + + it('handles decimal without leading zero', () => { + const result = formatAmountWithLocaleSeparators('.5'); + expect(result).toBe('0.5'); + }); + + it('handles trailing decimal point', () => { + const result = formatAmountWithLocaleSeparators('100.'); + + // parseFloat('100.') = 100, no decimal places + expect(mockGetIntlNumberFormatter).toHaveBeenCalledWith('en', { + useGrouping: true, + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }); + expect(result).toBe('100'); + }); + + it('handles negative numbers', () => { + const result = formatAmountWithLocaleSeparators('-1234.56'); + expect(result).toBe('-1,234.56'); + }); + + it('handles zero with decimals', () => { + const result = formatAmountWithLocaleSeparators('0.00'); + + expect(mockGetIntlNumberFormatter).toHaveBeenCalledWith('en', { + useGrouping: true, + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + expect(result).toBe('0'); + }); + + it('handles numbers with leading zeros', () => { + const result = formatAmountWithLocaleSeparators('0001234'); + // parseFloat removes leading zeros + expect(result).toBe('1,234'); + }); + }); + + describe('error handling', () => { + it('returns original value when formatter throws error', () => { + mockGetIntlNumberFormatter.mockReturnValue({ + format: () => { + throw new Error('Formatting error'); + }, + } as unknown as Intl.NumberFormat); + + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + const result = formatAmountWithLocaleSeparators('1234.56'); + + expect(result).toBe('1234.56'); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Number formatting error:', + expect.any(Error), + ); + + consoleErrorSpy.mockRestore(); + }); + + it('handles null getIntlNumberFormatter return', () => { + mockGetIntlNumberFormatter.mockReturnValue( + null as unknown as Intl.NumberFormat, + ); + + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + const result = formatAmountWithLocaleSeparators('1234.56'); + + // Should fallback to original value + expect(result).toBe('1234.56'); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Number formatting error:', + expect.any(TypeError), + ); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('locale usage', () => { + it('uses I18n.locale for formatting', () => { + formatAmountWithLocaleSeparators('1234.56'); + + expect(mockGetIntlNumberFormatter).toHaveBeenCalledWith( + 'en', + expect.any(Object), + ); + }); + + it('calls formatter with correct options', () => { + formatAmountWithLocaleSeparators('1000.123'); + + expect(mockGetIntlNumberFormatter).toHaveBeenCalledWith('en', { + useGrouping: true, + minimumFractionDigits: 3, + maximumFractionDigits: 3, + }); + }); + + it('enables grouping for all formatted values', () => { + formatAmountWithLocaleSeparators('5000'); + + const options = mockGetIntlNumberFormatter.mock.calls[0][1]; + expect(options?.useGrouping).toBe(true); + }); + }); + + describe('special number formats', () => { + it('handles scientific notation input', () => { + const result = formatAmountWithLocaleSeparators('1e3'); + // parseFloat('1e3') = 1000 + expect(result).toBe('1,000'); + }); + + it('handles numbers with plus sign', () => { + const result = formatAmountWithLocaleSeparators('+1234'); + expect(result).toBe('1,234'); + }); + + it('handles very precise decimals', () => { + const result = formatAmountWithLocaleSeparators('0.123456789012345'); + expect(result).toBe('0.123456789012345'); + }); + }); + + describe('boundary conditions', () => { + it('handles single digit', () => { + const result = formatAmountWithLocaleSeparators('5'); + expect(result).toBe('5'); + }); + + it('handles 999 (no separator needed)', () => { + const result = formatAmountWithLocaleSeparators('999'); + expect(result).toBe('999'); + }); + + it('handles 1000 (first separator)', () => { + const result = formatAmountWithLocaleSeparators('1000'); + expect(result).toBe('1,000'); + }); + + it('handles decimal point only', () => { + const result = formatAmountWithLocaleSeparators('.'); + // parseFloat('.') = NaN + expect(result).toBe('.'); + }); + }); +}); diff --git a/app/components/UI/Bridge/utils/formatAmountWithLocaleSeparators.ts b/app/components/UI/Bridge/utils/formatAmountWithLocaleSeparators.ts new file mode 100644 index 00000000000..c932db424c6 --- /dev/null +++ b/app/components/UI/Bridge/utils/formatAmountWithLocaleSeparators.ts @@ -0,0 +1,33 @@ +import I18n from '../../../../../locales/i18n'; +import { getIntlNumberFormatter } from '../../../../util/intl'; + +/** + * Formats a number string with locale-appropriate separators + * Uses Intl.NumberFormat to respect user's locale (e.g., en-US uses commas, de-DE uses periods) + */ +export const formatAmountWithLocaleSeparators = (value: string): string => { + if (!value || value === '0') return value; + + const numericValue = parseFloat(value); + if (isNaN(numericValue)) return value; + + // Determine the number of decimal places in the original value + const decimalPlaces = value.includes('.') + ? value.split('.')[1]?.length || 0 + : 0; + + try { + // Format with locale-appropriate separators using user's locale + const formatted = getIntlNumberFormatter(I18n.locale, { + useGrouping: true, + minimumFractionDigits: decimalPlaces, + maximumFractionDigits: decimalPlaces, + }).format(numericValue); + + return formatted; + } catch (error) { + // Fallback to simple comma formatting if Intl fails + console.error('Number formatting error:', error); + return value; + } +}; diff --git a/app/components/UI/Earn/LendingLearnMoreModal/__snapshots__/LendingLearnMoreModal.test.tsx.snap b/app/components/UI/Earn/LendingLearnMoreModal/__snapshots__/LendingLearnMoreModal.test.tsx.snap index 5b9b37dec43..204257cf781 100644 --- a/app/components/UI/Earn/LendingLearnMoreModal/__snapshots__/LendingLearnMoreModal.test.tsx.snap +++ b/app/components/UI/Earn/LendingLearnMoreModal/__snapshots__/LendingLearnMoreModal.test.tsx.snap @@ -117,11 +117,11 @@ exports[`LendingLearnMoreModal render lending history apy chart 1`] = ` "flexDirection": "row", "gap": 16, "height": 56, + "paddingLeft": 8, + "paddingRight": 8, }, false, - { - "paddingHorizontal": 16, - }, + undefined, ] } testID="header" @@ -145,57 +145,120 @@ exports[`LendingLearnMoreModal render lending history apy chart 1`] = ` ] } > - - How it works - + + How it works + + - - - + accessibilityValue={ + { + "max": undefined, + "min": undefined, + "now": undefined, + "text": undefined, + } + } + accessible={true} + collapsable={false} + focusable={true} + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + style={ + [ + { + "alignItems": "center", + "backgroundColor": "transparent", + "borderRadius": 2, + "height": 32, + "justifyContent": "center", + "opacity": 1, + "width": 32, + }, + undefined, + ] + } + testID="button-icon" + > + + + diff --git a/app/components/UI/Earn/LendingLearnMoreModal/index.tsx b/app/components/UI/Earn/LendingLearnMoreModal/index.tsx index 61db60f4081..a16229c9e75 100644 --- a/app/components/UI/Earn/LendingLearnMoreModal/index.tsx +++ b/app/components/UI/Earn/LendingLearnMoreModal/index.tsx @@ -4,11 +4,12 @@ import { useStyles } from '../../../hooks/useStyles'; import BottomSheet, { BottomSheetRef, } from '../../../../component-library/components/BottomSheets/BottomSheet'; -import BottomSheetHeader from '../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import HeaderCenter from '../../../../component-library/components-temp/HeaderCenter'; import Text, { TextColor, TextVariant, } from '../../../../component-library/components/Texts/Text'; +import { useTheme } from '../../../../util/theme'; import { Linking, View } from 'react-native'; import { strings } from '../../../../../locales/i18n'; import BottomSheetFooter, { @@ -137,6 +138,7 @@ const CHART_HEIGHT = 300; // Adjust to your chart's height export const LendingLearnMoreModal = () => { const { styles } = useStyles(styleSheet, {}); + const { colors } = useTheme(); const [assetSymbol, setAssetSymbol] = useState(null); const route = useRoute(); @@ -285,11 +287,10 @@ export const LendingLearnMoreModal = () => { return ( - - - {strings('earn.how_it_works')} - - + {showChart && ( { value: 90, }, ], + color: colors.success.default, }} isLoading={isLoadingMarketApys} /> diff --git a/app/components/UI/Earn/Views/EarnInputView/EarnInputView.test.tsx b/app/components/UI/Earn/Views/EarnInputView/EarnInputView.test.tsx index 25c21643ae5..4d6f2ef8494 100644 --- a/app/components/UI/Earn/Views/EarnInputView/EarnInputView.test.tsx +++ b/app/components/UI/Earn/Views/EarnInputView/EarnInputView.test.tsx @@ -27,13 +27,13 @@ import { } from '../../../../../util/test/renderWithProvider'; import { flushPromises } from '../../../../../util/test/utils'; import useMetrics from '../../../../hooks/useMetrics/useMetrics'; -import { getStakingNavbar } from '../../../Navbar'; import { MOCK_ETH_MAINNET_ASSET, MOCK_GET_VAULT_RESPONSE, } from '../../../Stake/__mocks__/stakeMockData'; import { MOCK_VAULT_APY_AVERAGES } from '../../../Stake/components/PoolStakingLearnMoreModal/mockVaultRewards'; import { EVENT_PROVIDERS } from '../../../Stake/constants/events'; +import { EVENT_LOCATIONS } from '../../constants/events/earnEvents'; // eslint-disable-next-line import/no-namespace import * as useBalance from '../../../Stake/hooks/useBalance'; import usePoolStakedDeposit from '../../../Stake/hooks/usePoolStakedDeposit'; @@ -115,9 +115,7 @@ jest.mock('../../hooks/useEarnTokens', () => ({ jest.mock('../../../../hooks/useMetrics/useMetrics'); -jest.mock('../../../Navbar', () => ({ - getStakingNavbar: jest.fn().mockReturnValue({}), -})); +const mockGoBack = jest.fn(); jest.mock('@react-navigation/native', () => { const actualReactNavigation = jest.requireActual('@react-navigation/native'); @@ -127,6 +125,7 @@ jest.mock('@react-navigation/native', () => { navigate: mockNavigate, setOptions: mockSetOptions, reset: mockReset, + goBack: mockGoBack, dangerouslyGetParent: () => ({ pop: mockPop, }), @@ -361,7 +360,6 @@ describe('EarnInputView', () => { }; const mockTrackEvent = jest.fn(); const useMetricsMock = jest.mocked(useMetrics); - const mockGetStakingNavbar = jest.mocked(getStakingNavbar); beforeEach(() => { jest.clearAllMocks(); @@ -527,15 +525,8 @@ describe('EarnInputView', () => { name: 'params', }); - expect(mockGetStakingNavbar).toHaveBeenCalledWith( - 'Supply USDC', - expect.anything(), - expect.anything(), - expect.anything(), - expect.anything(), - expect.anything(), - null, - ); + // Verify the title is rendered in the HeaderCenter component + expect(getByText('Supply USDC')).toBeTruthy(); // "0" in the input display and on the keypad expect(getAllByText('0').length).toBe(2); @@ -1206,26 +1197,11 @@ describe('EarnInputView', () => { describe('title bar', () => { it('displays "Stake " for staking', () => { - selectStablecoinLendingEnabledFlagMock.mockReturnValue(true); - - render(EarnInputView, { - params: { - ...baseProps.route.params, - token: MOCK_USDC_MAINNET_ASSET, - }, - key: Routes.STAKING.STAKE, - name: 'params', - }); + // Default mock returns ETH with POOLED_STAKING experience + const { getByText } = renderComponent(); - expect(mockGetStakingNavbar).toHaveBeenCalledWith( - 'Stake ETH', - expect.anything(), - expect.anything(), - expect.anything(), - expect.anything(), - expect.anything(), - null, - ); + // Verify the title is rendered in the HeaderCenter component + expect(getByText('Stake ETH')).toBeTruthy(); }); }); @@ -1866,4 +1842,145 @@ describe('EarnInputView', () => { ); }); }); + + describe('HeaderCenter interactions', () => { + it('tracks STAKE_CANCEL_CLICKED event with token property when back button is pressed for staking', async () => { + selectStablecoinLendingEnabledFlagMock.mockReturnValue(false); + + const { getByTestId } = renderComponent(); + + mockTrackEvent.mockClear(); + + const backButton = getByTestId('button-icon'); + await act(async () => { + fireEvent.press(backButton); + }); + + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Stake Cancel Clicked', + properties: expect.objectContaining({ + selected_provider: EVENT_PROVIDERS.CONSENSYS, + location: EVENT_LOCATIONS.EARN_INPUT_VIEW, + experience: EARN_EXPERIENCES.POOLED_STAKING, + token: 'Ethereum', + }), + }), + ); + }); + + it('tracks EARN_INPUT_BACK_BUTTON_CLICKED event when back button is pressed for stablecoin lending', async () => { + selectStablecoinLendingEnabledFlagMock.mockReturnValue(true); + + (useEarnTokens as jest.Mock).mockReturnValue({ + getEarnToken: jest.fn(() => ({ + ...MOCK_USDC_MAINNET_ASSET, + balance: '100', + balanceFiat: '$100', + balanceMinimalUnit: '100000000', + balanceFormatted: '100 USDC', + balanceFiatNumber: 100, + symbol: 'USDC', + experience: { + type: EARN_EXPERIENCES.STABLECOIN_LENDING, + apr: '4.5%', + }, + })), + getOutputToken: jest.fn(() => ({ + ...MOCK_USDC_MAINNET_ASSET, + name: 'aUSDC', + symbol: 'aUSDC', + })), + }); + + const routeParamsWithUSDC: EarnInputViewProps['route'] = { + params: { + token: MOCK_USDC_MAINNET_ASSET, + }, + key: Routes.STAKING.STAKE, + name: 'params', + }; + + const { getAllByTestId } = render(EarnInputView, routeParamsWithUSDC); + + mockTrackEvent.mockClear(); + + // First button-icon is the back button + const buttonIcons = getAllByTestId('button-icon'); + const backButton = buttonIcons[0]; + await act(async () => { + fireEvent.press(backButton); + }); + + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Earn Input Back Button Clicked', + properties: expect.objectContaining({ + selected_provider: EVENT_PROVIDERS.CONSENSYS, + location: EVENT_LOCATIONS.EARN_INPUT_VIEW, + experience: EARN_EXPERIENCES.STABLECOIN_LENDING, + token: 'USDC', + }), + }), + ); + }); + + it('tracks TOOLTIP_OPENED event when info button is pressed for stablecoin lending', async () => { + selectStablecoinLendingEnabledFlagMock.mockReturnValue(true); + + (useEarnTokens as jest.Mock).mockReturnValue({ + getEarnToken: jest.fn(() => ({ + ...MOCK_USDC_MAINNET_ASSET, + balance: '100', + balanceFiat: '$100', + balanceMinimalUnit: '100000000', + balanceFormatted: '100 USDC', + balanceFiatNumber: 100, + symbol: 'USDC', + experience: { + type: EARN_EXPERIENCES.STABLECOIN_LENDING, + apr: '4.5%', + }, + })), + getOutputToken: jest.fn(() => ({ + ...MOCK_USDC_MAINNET_ASSET, + name: 'aUSDC', + symbol: 'aUSDC', + })), + }); + + const routeParamsWithUSDC: EarnInputViewProps['route'] = { + params: { + token: MOCK_USDC_MAINNET_ASSET, + }, + key: Routes.STAKING.STAKE, + name: 'params', + }; + + const { getAllByTestId } = render(EarnInputView, routeParamsWithUSDC); + + mockTrackEvent.mockClear(); + + // Second button-icon is the info button (when stablecoin lending is enabled) + const buttonIcons = getAllByTestId('button-icon'); + const infoButton = buttonIcons[1]; + await act(async () => { + fireEvent.press(infoButton); + }); + + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Tooltip Opened', + properties: expect.objectContaining({ + selected_provider: EVENT_PROVIDERS.CONSENSYS, + text: 'Tooltip Opened', + location: EVENT_LOCATIONS.EARN_INPUT_VIEW, + tooltip_name: 'Lending Historic Market APY Graph', + experience: EARN_EXPERIENCES.STABLECOIN_LENDING, + token: 'USDC', + }), + }), + ); + }); + }); }); diff --git a/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx b/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx index 64a3caf803e..7bd5904e1f6 100644 --- a/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx +++ b/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx @@ -10,7 +10,13 @@ import { import BigNumber from 'bignumber.js'; import { formatEther } from 'ethers/lib/utils'; import { debounce } from 'lodash'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { View } from 'react-native'; import { useSelector } from 'react-redux'; import { strings } from '../../../../../../locales/i18n'; @@ -38,7 +44,8 @@ import { addTransactionBatch } from '../../../../../util/transaction-controller' import Keypad from '../../../../Base/Keypad'; import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics'; import { useStyles } from '../../../../hooks/useStyles'; -import { getStakingNavbar } from '../../../Navbar'; +import HeaderCenter from '../../../../../component-library/components-temp/HeaderCenter'; +import { IconName } from '@metamask/design-system-react-native'; import ScreenLayout from '../../../Ramp/Aggregator/components/ScreenLayout'; import QuickAmounts from '../../../Stake/components/QuickAmounts'; import { EVENT_PROVIDERS } from '../../../Stake/constants/events'; @@ -118,7 +125,7 @@ const EarnInputView = () => { : (contractExchangeRates?.[token.address as Hex]?.price ?? 0); // other hooks - const { styles, theme } = useStyles(styleSheet, {}); + const { styles } = useStyles(styleSheet, {}); const { trackEvent, createEventBuilder } = useMetrics(); const { attemptDepositTransaction } = usePoolStakedDeposit(); const { getEarnToken } = useEarnTokens(); @@ -135,7 +142,7 @@ const EarnInputView = () => { confirmStake: tronConfirmStake, tronAccountId, } = useTronStake({ token }); - const { apyPercent: tronApyPercent } = useTronStakeApy(); + useTronStakeApy(); ///: END:ONLY_INCLUDE_IF // Flag to conditionally show Tron-specific UI (false in non-Tron builds) @@ -234,7 +241,7 @@ const EarnInputView = () => { useEndTraceOnMount(TraceName.EarnDepositScreen); - const navigateToLearnMoreModal = () => { + const navigateToLearnMoreModal = useCallback(() => { const tokenExperience = earnToken?.experience?.type; if (tokenExperience === EARN_EXPERIENCES.POOLED_STAKING) { @@ -263,7 +270,13 @@ const EarnInputView = () => { params: { asset: earnToken }, }); } - }; + }, [ + earnToken, + navigation, + ///: BEGIN:ONLY_INCLUDE_IF(tron) + isTronNative, + ///: END:ONLY_INCLUDE_IF + ]); const handleQuickAmountPressWithTracking = useCallback( ({ value }: { value: number }) => { @@ -827,95 +840,83 @@ const EarnInputView = () => { }, []), ); - const stakingNavBarOptions = { - hasCancelButton: true, - hasBackButton: false, - }; - const stakingNavBarEventOptions = { - cancelButtonEvent: { - event: MetaMetricsEvents.STAKE_CANCEL_CLICKED, - properties: { - selected_provider: EVENT_PROVIDERS.CONSENSYS, - location: EVENT_LOCATIONS.EARN_INPUT_VIEW, - experience: EARN_EXPERIENCES.POOLED_STAKING, - token: token.symbol, - }, - }, - }; - const earnNavBarOptions = { - hasCancelButton: false, - hasBackButton: true, - hasIconButton: true, - handleIconPress: navigateToLearnMoreModal, - }; - const earnNavBarEventOptions = { - backButtonEvent: { - event: MetaMetricsEvents.EARN_INPUT_BACK_BUTTON_CLICKED, - properties: { - selected_provider: EVENT_PROVIDERS.CONSENSYS, - location: EVENT_LOCATIONS.EARN_INPUT_VIEW, - experience: EARN_EXPERIENCES.STABLECOIN_LENDING, - token: token.symbol, - }, - }, - iconButtonEvent: { - event: MetaMetricsEvents.TOOLTIP_OPENED, - properties: { - selected_provider: EVENT_PROVIDERS.CONSENSYS, - text: 'Tooltip Opened', - location: EVENT_LOCATIONS.EARN_INPUT_VIEW, - tooltip_name: 'Lending Historic Market APY Graph', - experience: EARN_EXPERIENCES.STABLECOIN_LENDING, - token: token.symbol, - apr: `${earnToken?.experience.apr}%`, - }, - }, - }; - const navBarOptions = isStablecoinLendingEnabled - ? earnNavBarOptions - : stakingNavBarOptions; - const navBarEventOptions = isStablecoinLendingEnabled - ? earnNavBarEventOptions - : stakingNavBarEventOptions; + const handleBackPress = useCallback(() => { + if (shouldLogStablecoinEvent()) { + trackEvent( + createEventBuilder(MetaMetricsEvents.EARN_INPUT_BACK_BUTTON_CLICKED) + .addProperties({ + selected_provider: EVENT_PROVIDERS.CONSENSYS, + location: EVENT_LOCATIONS.EARN_INPUT_VIEW, + experience: EARN_EXPERIENCES.STABLECOIN_LENDING, + token: token.symbol, + }) + .build(), + ); + } else if (shouldLogStakingEvent()) { + trackEvent( + createEventBuilder(MetaMetricsEvents.STAKE_CANCEL_CLICKED) + .addProperties({ + selected_provider: EVENT_PROVIDERS.CONSENSYS, + location: EVENT_LOCATIONS.EARN_INPUT_VIEW, + experience: EARN_EXPERIENCES.POOLED_STAKING, + token: token.symbol, + }) + .build(), + ); + } + navigation.goBack(); + }, [ + shouldLogStablecoinEvent, + shouldLogStakingEvent, + trackEvent, + createEventBuilder, + token.symbol, + navigation, + ]); + + const handleInfoPress = useCallback(() => { + trackEvent( + createEventBuilder(MetaMetricsEvents.TOOLTIP_OPENED) + .addProperties({ + selected_provider: EVENT_PROVIDERS.CONSENSYS, + text: 'Tooltip Opened', + location: EVENT_LOCATIONS.EARN_INPUT_VIEW, + tooltip_name: 'Lending Historic Market APY Graph', + experience: EARN_EXPERIENCES.STABLECOIN_LENDING, + token: token.symbol, + apr: `${earnToken?.experience.apr}%`, + }) + .build(), + ); + navigateToLearnMoreModal(); + }, [ + trackEvent, + createEventBuilder, + token.symbol, + earnToken?.experience.apr, + navigateToLearnMoreModal, + ]); useEffect(() => { + navigation.setOptions({ + headerShown: false, + }); + }, [navigation]); + + const headerTitle = useMemo(() => { const isLending = earnToken?.experience?.type === EARN_EXPERIENCES.STABLECOIN_LENDING; const tokenLabel = earnToken?.ticker ?? earnToken?.symbol ?? earnToken?.name ?? ''; - const title = isLending + return isLending ? `${strings('earn.supply')} ${tokenLabel}` : `${strings('stake.stake')} ${tokenLabel}`; - - navigation.setOptions( - getStakingNavbar( - title, - navigation, - theme.colors, - navBarOptions, - navBarEventOptions, - ///: BEGIN:ONLY_INCLUDE_IF(tron) - earnToken, - isTronEnabled ? tronApyPercent : null, - ///: END:ONLY_INCLUDE_IF - ), - ); }, [ - navigation, - token, - theme.colors, - navBarEventOptions, - navBarOptions, earnToken?.experience?.type, earnToken?.ticker, earnToken?.symbol, earnToken?.name, - earnToken, - ///: BEGIN:ONLY_INCLUDE_IF(tron) - isTronEnabled, - tronApyPercent, - ///: END:ONLY_INCLUDE_IF ]); useEffect(() => { @@ -1000,6 +1001,16 @@ const EarnInputView = () => { return ( + { ///: BEGIN:ONLY_INCLUDE_IF(tron) isTronEnabled && ( diff --git a/app/components/UI/Earn/Views/EarnInputView/__snapshots__/EarnInputView.test.tsx.snap b/app/components/UI/Earn/Views/EarnInputView/__snapshots__/EarnInputView.test.tsx.snap index 5576f5cd616..6c043220eb2 100644 --- a/app/components/UI/Earn/Views/EarnInputView/__snapshots__/EarnInputView.test.tsx.snap +++ b/app/components/UI/Earn/Views/EarnInputView/__snapshots__/EarnInputView.test.tsx.snap @@ -343,6 +343,161 @@ exports[`EarnInputView render matches snapshot 1`] = ` ] } > + + + + + + + + + + + + + + Stake ETH + + + + + + + + + + + + + + + + + + + + + Stake ETH + + + + + + + { }); }); }); + + describe('HeaderCenter close button', () => { + it('invokes handleClose when close button is pressed', async () => { + const { getByTestId } = renderWithProvider( + + + , + { state: initialState }, + ); + + const closeButton = getByTestId('earn-token-list-close-button'); + + // Press the close button - this invokes handleClose which calls onCloseBottomSheet + await act(async () => { + fireEvent.press(closeButton); + }); + + // The close button should be pressable without errors + // handleClose calls bottomSheetRef.current?.onCloseBottomSheet() + expect(closeButton).toBeDefined(); + }); + }); }); diff --git a/app/components/UI/Earn/components/EarnTokenList/__snapshots__/EarnTokenList.test.tsx.snap b/app/components/UI/Earn/components/EarnTokenList/__snapshots__/EarnTokenList.test.tsx.snap index 67be5fe99dc..2918232c45b 100644 --- a/app/components/UI/Earn/components/EarnTokenList/__snapshots__/EarnTokenList.test.tsx.snap +++ b/app/components/UI/Earn/components/EarnTokenList/__snapshots__/EarnTokenList.test.tsx.snap @@ -137,11 +137,11 @@ exports[`EarnTokenList render matches snapshot 1`] = ` "flexDirection": "row", "gap": 16, "height": 56, + "paddingLeft": 8, + "paddingRight": 8, }, false, - { - "paddingHorizontal": 16, - }, + undefined, ] } testID="header" @@ -165,58 +165,120 @@ exports[`EarnTokenList render matches snapshot 1`] = ` ] } > - - Select a token to deposit - + + Select a token to deposit + + - - - + accessibilityValue={ + { + "max": undefined, + "min": undefined, + "now": undefined, + "text": undefined, + } + } + accessible={true} + collapsable={false} + focusable={true} + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + style={ + [ + { + "alignItems": "center", + "backgroundColor": "transparent", + "borderRadius": 2, + "height": 32, + "justifyContent": "center", + "opacity": 1, + "width": 32, + }, + undefined, + ] + } + testID="earn-token-list-close-button" + > + + + diff --git a/app/components/UI/Earn/components/EarnTokenList/index.tsx b/app/components/UI/Earn/components/EarnTokenList/index.tsx index db1d98e9ec4..0e2562b68b5 100644 --- a/app/components/UI/Earn/components/EarnTokenList/index.tsx +++ b/app/components/UI/Earn/components/EarnTokenList/index.tsx @@ -8,11 +8,8 @@ import React, { import BottomSheet, { BottomSheetRef, } from '../../../../../component-library/components/BottomSheets/BottomSheet'; -import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader'; -import Text, { - TextColor, - TextVariant, -} from '../../../../../component-library/components/Texts/Text'; +import { TextColor } from '../../../../../component-library/components/Texts/Text'; +import HeaderCenter from '../../../../../component-library/components-temp/HeaderCenter'; import { View } from 'react-native'; import { useStyles } from '../../../../hooks/useStyles'; import styleSheet from './EarnTokenList.styles'; @@ -383,16 +380,15 @@ const EarnTokenList = () => { return ( - - - {params?.onItemPressScreen === EARN_INPUT_VIEW_ACTIONS.WITHDRAW - ? strings('stake.select_a_token_to_withdraw') - : strings('stake.select_a_token_to_deposit')} - - + /> = { '0x1': ['USDT'], }; + +export const MINIMUM_BALANCE_FOR_EARN_CTA = 0.01; diff --git a/app/components/UI/Perps/constants/hyperLiquidConfig.ts b/app/components/UI/Perps/constants/hyperLiquidConfig.ts index fb87c996ddd..539294bc185 100644 --- a/app/components/UI/Perps/constants/hyperLiquidConfig.ts +++ b/app/components/UI/Perps/constants/hyperLiquidConfig.ts @@ -370,8 +370,6 @@ export const HIP3_ASSET_MARKET_TYPES: Record< * On testnet, there are many HIP-3 DEXs (test deployments from various builders). * Subscribing to all of them causes connection/subscription overload and instability. * This configuration limits which DEXs are discovered and subscribed to on testnet. - * - * On mainnet, full DEX discovery continues unchanged. */ export const TESTNET_HIP3_CONFIG = { /** @@ -388,6 +386,25 @@ export const TESTNET_HIP3_CONFIG = { AutoDiscoverAll: false, } as const; +/** + * Mainnet-specific HIP-3 DEX configuration + * + * On mainnet, DEX filtering is dynamically determined from the allowlist markets + * feature flag. This avoids hardcoding DEX names and ensures consistency with + * the market filtering logic. + * + * When AutoDiscoverAll is false and no allowlist is provided, only the main DEX is used. + * When an allowlist is provided, DEXs are extracted from the allowlist patterns. + */ +export const MAINNET_HIP3_CONFIG = { + /** + * Set to true to enable full HIP-3 discovery on mainnet + * When false, DEXs are filtered based on the allowlist markets feature flag + * (recommended for production to reduce subscription overhead) + */ + AutoDiscoverAll: false, +} as const; + /** * HIP-3 margin management configuration * Controls margin buffers and auto-rebalance behavior for HIP-3 DEXes with isolated margin diff --git a/app/components/UI/Perps/controllers/PerpsController.ts b/app/components/UI/Perps/controllers/PerpsController.ts index 1a13813a398..4eab31239bd 100644 --- a/app/components/UI/Perps/controllers/PerpsController.ts +++ b/app/components/UI/Perps/controllers/PerpsController.ts @@ -1628,8 +1628,10 @@ export class PerpsController extends BaseController< }) .catch((error) => { // Check if user denied/cancelled the transaction - const errorMessage = - error instanceof Error ? error.message : String(error); + const errorMessage = ensureError( + error, + 'PerpsController.initiateDeposit', + ).message; const userCancelled = errorMessage.includes('User denied') || errorMessage.includes('User rejected') || @@ -1677,8 +1679,10 @@ export class PerpsController extends BaseController< }; } catch (error) { // Check if user denied/cancelled the transaction - const errorMessage = - error instanceof Error ? error.message : String(error); + const errorMessage = ensureError( + error, + 'PerpsController.initiateDeposit', + ).message; const userCancelled = errorMessage.includes('User denied') || errorMessage.includes('User rejected') || @@ -2143,10 +2147,7 @@ export class PerpsController extends BaseController< return { success: false, isTestnet: this.state.isTestnet, - error: - error instanceof Error - ? error.message - : PERPS_ERROR_CODES.UNKNOWN_ERROR, + error: ensureError(error, 'PerpsController.toggleTestnet').message, }; } finally { this.isReinitializing = false; diff --git a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts index 202d16c9d96..a20b41654eb 100644 --- a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts +++ b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts @@ -241,9 +241,10 @@ const createMockInfoClient = (overrides: Record = {}) => ({ ]), spotMeta: jest.fn().mockResolvedValue({ tokens: [ - { name: 'USDC', tokenId: '0xdef456' }, - { name: 'USDT', tokenId: '0x789abc' }, + { name: 'USDC', tokenId: '0xdef456', index: 0 }, + { name: 'USDT', tokenId: '0x789abc', index: 1 }, ], + universe: [], }), ...overrides, }); @@ -6397,7 +6398,8 @@ describe('HyperLiquidProvider', () => { mockClientService.getInfoClient = jest.fn().mockReturnValue( createMockInfoClient({ spotMeta: jest.fn().mockResolvedValue({ - tokens: [{ name: 'USDC', tokenId: '0xabc123' }], + tokens: [{ name: 'USDC', tokenId: '0xabc123', index: 0 }], + universe: [], }), }), ); @@ -6487,7 +6489,8 @@ describe('HyperLiquidProvider', () => { it('calls getUsdcTokenId to get correct token', async () => { // Arrange const mockSpotMeta = jest.fn().mockResolvedValue({ - tokens: [{ name: 'USDC', tokenId: '0xspecific' }], + tokens: [{ name: 'USDC', tokenId: '0xspecific', index: 0 }], + universe: [], }); mockClientService.getInfoClient = jest .fn() @@ -6597,9 +6600,10 @@ describe('HyperLiquidProvider', () => { // Arrange const mockSpotMeta = { tokens: [ - { name: 'USDC', tokenId: '0xdef456' }, - { name: 'USDT', tokenId: '0x789abc' }, + { name: 'USDC', tokenId: '0xdef456', index: 0 }, + { name: 'USDT', tokenId: '0x789abc', index: 1 }, ], + universe: [], }; mockClientService.getInfoClient = jest.fn().mockReturnValue( createMockInfoClient({ @@ -6619,7 +6623,8 @@ describe('HyperLiquidProvider', () => { it('throws error when USDC token not found in metadata', async () => { // Arrange const mockSpotMeta = { - tokens: [{ name: 'USDT', tokenId: '0x789abc' }], + tokens: [{ name: 'USDT', tokenId: '0x789abc', index: 0 }], + universe: [], }; mockClientService.getInfoClient = jest.fn().mockReturnValue( createMockInfoClient({ diff --git a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts index 9ae800796ca..5102cf7fbc4 100644 --- a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts +++ b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts @@ -12,6 +12,7 @@ import { HIP3_FEE_CONFIG, HIP3_MARGIN_CONFIG, HYPERLIQUID_WITHDRAWAL_MINUTES, + MAINNET_HIP3_CONFIG, REFERRAL_CONFIG, TESTNET_HIP3_CONFIG, TRADING_DEFAULTS, @@ -63,6 +64,7 @@ import type { MetaResponse, PerpsAssetCtx, FrontendOrder, + SpotMetaResponse, } from '../../types/hyperliquid-types'; import { createErrorResult, @@ -254,6 +256,10 @@ export class HyperLiquidProvider implements PerpsProvider { // Filtering is applied on-demand (cheap array operations) - no need for separate processed cache private cachedMetaByDex = new Map(); + // Session cache for spot metadata (contains USDC/USDH token info for HIP-3 collateral checks) + // Pre-fetched in ensureReadyForTrading() to avoid API failures during order placement + private cachedSpotMeta: SpotMetaResponse | null = null; + // Cache for perpDexs data (deployerFeeScale for dynamic fee calculation) // TTL-based cache - fee scales rarely change private perpDexsCache: { @@ -573,7 +579,10 @@ export class HyperLiquidProvider implements PerpsProvider { { user: userAddress, network, - error: error instanceof Error ? error.message : String(error), + error: ensureError( + error, + 'HyperLiquidProvider.ensureDexAbstractionEnabled', + ).message, }, ); @@ -679,6 +688,20 @@ export class HyperLiquidProvider implements PerpsProvider { ); this.tradingSetupPromise = (async () => { + // Pre-fetch spotMeta for HIP-3 operations (non-blocking if it fails) + // This ensures token info (USDC/USDH indices) is available during order placement + if (this.hip3Enabled) { + try { + await this.getCachedSpotMeta(); + } catch (error) { + this.deps.debugLogger.log( + '[ensureReadyForTrading] spotMeta pre-fetch failed, will retry when needed', + error, + ); + // Don't throw - spotMeta will be fetched on-demand if needed + } + } + // Attempt to enable native balance abstraction await this.ensureDexAbstractionEnabled(); @@ -1003,9 +1026,53 @@ export class HyperLiquidProvider implements PerpsProvider { 'HyperLiquidProvider: Testnet - AUTO_DISCOVER_ALL enabled, using all DEXs', { totalDexCount: availableHip3Dexs.length + 1 }, ); + } else { + // Mainnet-specific filtering: Extract allowed DEXs from the allowlist patterns + // This reduces WebSocket subscription overhead dynamically based on feature flags + const { AutoDiscoverAll } = MAINNET_HIP3_CONFIG; + + if (!AutoDiscoverAll) { + // Extract unique DEX names from allowlist patterns + // Patterns like "xyz:*", "xyz:TSLA", or "xyz" all indicate DEX "xyz" + const allowedDexsFromAllowlist = this.extractDexsFromAllowlist(); + + if (allowedDexsFromAllowlist.length === 0) { + // No HIP-3 DEXs in allowlist - main DEX only + this.deps.debugLogger.log( + 'HyperLiquidProvider: Mainnet - using main DEX only (no HIP-3 DEXs in allowlist)', + { + availableHip3Dexs: availableHip3Dexs.length, + allowlistMarkets: this.allowlistMarkets, + }, + ); + this.cachedValidatedDexs = [null]; + return this.cachedValidatedDexs; + } + + // Filter to DEXs that are both available AND in the allowlist + const filteredDexs = availableHip3Dexs.filter((dex) => + allowedDexsFromAllowlist.includes(dex), + ); + this.deps.debugLogger.log( + 'HyperLiquidProvider: Mainnet - filtered to allowlist DEXs', + { + allowedDexsFromAllowlist, + filteredDexs, + availableHip3Dexs: availableHip3Dexs.length, + }, + ); + this.cachedValidatedDexs = [null, ...filteredDexs]; + return this.cachedValidatedDexs; + } + + // AUTO_DISCOVER_ALL is true - proceed with all DEXs + this.deps.debugLogger.log( + 'HyperLiquidProvider: Mainnet - AUTO_DISCOVER_ALL enabled, using all DEXs', + { totalDexCount: availableHip3Dexs.length + 1 }, + ); } - // Mainnet (or testnet with AUTO_DISCOVER_ALL): Return all DEXs + // Fallback: Return all DEXs (when AUTO_DISCOVER_ALL is true) // Market filtering is applied at subscription data layer this.deps.debugLogger.log( 'HyperLiquidProvider: All DEXs enabled (market filtering at data layer)', @@ -1019,6 +1086,41 @@ export class HyperLiquidProvider implements PerpsProvider { return this.cachedValidatedDexs; } + /** + * Extract unique DEX names from allowlist market patterns + * Patterns can be: "xyz:*" (wildcard), "xyz:TSLA" (exact), or "xyz" (DEX shorthand) + * + * @returns Array of unique DEX names from the allowlist + */ + private extractDexsFromAllowlist(): string[] { + if (this.allowlistMarkets.length === 0) { + return []; + } + + const dexNames = new Set(); + + for (const pattern of this.allowlistMarkets) { + // Pattern formats: + // - "xyz:*" -> DEX "xyz" (wildcard) + // - "xyz:TSLA" -> DEX "xyz" (exact match) + // - "xyz" -> DEX "xyz" (shorthand) + const colonIndex = pattern.indexOf(':'); + if (colonIndex > 0) { + // Has colon - extract DEX prefix + const dex = pattern.substring(0, colonIndex); + dexNames.add(dex); + } else if (pattern.length > 0 && !pattern.includes('*')) { + // No colon and not a wildcard - could be DEX shorthand + // Only add if it looks like a valid DEX name (lowercase alphanumeric) + if (/^[a-z][a-z0-9]*$/i.test(pattern)) { + dexNames.add(pattern.toLowerCase()); + } + } + } + + return Array.from(dexNames); + } + /** * Get cached meta response for a DEX, fetching from API if not cached * This helper consolidates cache logic to avoid redundant API calls across the provider @@ -1077,6 +1179,36 @@ export class HyperLiquidProvider implements PerpsProvider { return meta; } + /** + * Fetch spot metadata with session-based caching + * Contains token info (USDC, USDH indices) needed for HIP-3 collateral checks + * Pre-fetched in ensureReadyForTrading() to ensure availability during order placement + * @returns SpotMetaResponse with tokens and universe data + */ + private async getCachedSpotMeta(): Promise { + if (this.cachedSpotMeta) { + this.deps.debugLogger.log('[getCachedSpotMeta] Using cached spotMeta', { + tokensCount: this.cachedSpotMeta.tokens.length, + universeCount: this.cachedSpotMeta.universe.length, + }); + return this.cachedSpotMeta; + } + + const infoClient = this.clientService.getInfoClient(); + const spotMeta = await infoClient.spotMeta(); + + this.cachedSpotMeta = spotMeta; + this.deps.debugLogger.log( + '[getCachedSpotMeta] Fetched and cached spotMeta', + { + tokensCount: spotMeta.tokens.length, + universeCount: spotMeta.universe.length, + }, + ); + + return spotMeta; + } + /** * Fetch perpDexs data with TTL-based caching * Returns deployerFeeScale info needed for dynamic fee calculation @@ -1269,8 +1401,7 @@ export class HyperLiquidProvider implements PerpsProvider { return this.cachedUsdcTokenId; } - const infoClient = this.clientService.getInfoClient(); - const spotMeta = await infoClient.spotMeta(); + const spotMeta = await this.getCachedSpotMeta(); const usdcToken = spotMeta.tokens.find((tok) => tok.name === 'USDC'); if (!usdcToken) { @@ -1291,8 +1422,7 @@ export class HyperLiquidProvider implements PerpsProvider { */ private async isUsdhCollateralDex(dexName: string): Promise { const meta = await this.getCachedMeta({ dexName }); - const infoClient = this.clientService.getInfoClient(); - const spotMeta = await infoClient.spotMeta(); + const spotMeta = await this.getCachedSpotMeta(); const collateralToken = spotMeta.tokens.find( (tok: { index: number }) => tok.index === meta.collateralToken, @@ -1403,7 +1533,10 @@ export class HyperLiquidProvider implements PerpsProvider { return { success: false, error: PERPS_ERROR_CODES.TRANSFER_FAILED }; } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); + const errorMsg = ensureError( + error, + 'HyperLiquidProvider.transferUSDCToPerps', + ).message; this.deps.debugLogger.log( 'HyperLiquidProvider: USDC transfer to spot failed', { @@ -1421,8 +1554,7 @@ export class HyperLiquidProvider implements PerpsProvider { private async swapUsdcToUsdh( amount: number, ): Promise<{ success: boolean; filledSize?: number; error?: string }> { - const infoClient = this.clientService.getInfoClient(); - const spotMeta = await infoClient.spotMeta(); + const spotMeta = await this.getCachedSpotMeta(); // Find USDH and USDC tokens by name const usdhToken = spotMeta.tokens.find( @@ -1464,6 +1596,7 @@ export class HyperLiquidProvider implements PerpsProvider { ); // Get current mid price + const infoClient = this.clientService.getInfoClient(); const allMids = await infoClient.allMids(); const pairKey = `@${usdhUsdcPair.index}`; const usdhPrice = parseFloat(allMids[pairKey] || '1'); @@ -1560,7 +1693,10 @@ export class HyperLiquidProvider implements PerpsProvider { return { success: true, filledSize }; } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); + const errorMsg = ensureError( + error, + 'HyperLiquidProvider.swapUSDCToUSDH', + ).message; this.deps.debugLogger.log('HyperLiquidProvider: USDC→USDH swap error', { error: errorMsg, }); @@ -1886,7 +2022,7 @@ export class HyperLiquidProvider implements PerpsProvider { * Map HyperLiquid API errors to standardized PERPS_ERROR_CODES */ private mapError(error: unknown): Error { - const message = error instanceof Error ? error.message : String(error); + const message = ensureError(error, 'HyperLiquidProvider.mapError').message; for (const [pattern, code] of Object.entries(this.errorMappings)) { if (message.toLowerCase().includes(pattern.toLowerCase())) { @@ -1895,7 +2031,7 @@ export class HyperLiquidProvider implements PerpsProvider { } // Return original error to preserve stack trace for unmapped errors - return error instanceof Error ? error : new Error(String(error)); + return ensureError(error, 'HyperLiquidProvider.mapError'); } /** @@ -2119,7 +2255,10 @@ export class HyperLiquidProvider implements PerpsProvider { '[ensureBuilderFeeApproval] Failed, cached to prevent retries', { network, - error: error instanceof Error ? error.message : String(error), + error: ensureError( + error, + 'HyperLiquidProvider.ensureBuilderFeeApproval', + ).message, }, ); @@ -3096,8 +3235,10 @@ export class HyperLiquidProvider implements PerpsProvider { } catch (error) { // Retry mechanism for $10 minimum order errors // This handles the case where UI price feed slightly differs from HyperLiquid's orderbook price - const errorMessage = - error instanceof Error ? error.message : String(error); + const errorMessage = ensureError( + error, + 'HyperLiquidProvider.placeOrder', + ).message; const isMinimumOrderError = errorMessage.includes('Order must have minimum value of $10') || errorMessage.includes('Order 0: Order must have minimum value'); @@ -4169,8 +4310,9 @@ export class HyperLiquidProvider implements PerpsProvider { success: true, }; } catch (error) { + const safeError = ensureError(error, 'HyperLiquidProvider.updateMargin'); this.deps.logger.error( - ensureError(error), + safeError, this.getErrorContext('updateMargin', { symbol: params.symbol, amount: params.amount, @@ -4178,7 +4320,7 @@ export class HyperLiquidProvider implements PerpsProvider { ); return { success: false, - error: error instanceof Error ? error.message : String(error), + error: safeError.message, }; } } @@ -5855,18 +5997,19 @@ export class HyperLiquidProvider implements PerpsProvider { error: errorMessage, }; } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; + const safeError = ensureError( + error, + 'HyperLiquidProvider.initiateWithdrawal', + ); this.deps.debugLogger.log('HyperLiquidProvider: WITHDRAWAL EXCEPTION', { - error: errorMessage, - errorType: - error instanceof Error ? error.constructor.name : typeof error, - stack: error instanceof Error ? error.stack : undefined, + error: safeError.message, + errorType: safeError.name, + stack: safeError.stack, params, timestamp: new Date().toISOString(), }); this.deps.logger.error( - ensureError(error), + safeError, this.getErrorContext('withdraw', { assetId: params.assetId, amount: params.amount, @@ -5959,17 +6102,21 @@ export class HyperLiquidProvider implements PerpsProvider { throw new Error(PERPS_ERROR_CODES.TRANSFER_FAILED); } catch (error) { + const safeError = ensureError( + error, + 'HyperLiquidProvider.transferToSpot', + ); this.deps.debugLogger.log('❌ HyperLiquidProvider: TRANSFER FAILED', { - error: error instanceof Error ? error.message : String(error), + error: safeError.message, params, }); this.deps.logger.error( - ensureError(error), + safeError, this.getErrorContext('transferBetweenDexs', { ...params }), ); return { success: false, - error: error instanceof Error ? error.message : String(error), + error: safeError.message, }; } } @@ -6555,12 +6702,15 @@ export class HyperLiquidProvider implements PerpsProvider { } } catch (error) { // Silently fall back to base rates + const safeError = ensureError( + error, + 'HyperLiquidProvider.getFeeSchedule', + ); this.deps.debugLogger.log( 'Fee API Call Failed - Falling Back to Base Rates', { - error: error instanceof Error ? error.message : String(error), - errorType: - error instanceof Error ? error.constructor.name : typeof error, + error: safeError.message, + errorType: safeError.name, fallbackTakerRate: FEE_RATES.taker, fallbackMakerRate: FEE_RATES.maker, userAddress: 'unknown', @@ -6687,6 +6837,7 @@ export class HyperLiquidProvider implements PerpsProvider { // NOTE: DexAbstractionCache is global and NOT cleared on disconnect // to prevent repeated signing requests across reconnections this.cachedMetaByDex.clear(); + this.cachedSpotMeta = null; this.perpDexsCache = { data: null, timestamp: 0 }; // Await pending initialization before clearing to prevent the IIFE from @@ -7029,7 +7180,8 @@ export class HyperLiquidProvider implements PerpsProvider { '[ensureReferralSet] Error, cached to prevent retries', { network, - error: error instanceof Error ? error.message : String(error), + error: ensureError(error, 'HyperLiquidProvider.ensureReferralSet') + .message, }, ); completeInFlight(); diff --git a/app/components/UI/Perps/hooks/usePerpsDepositStatus.test.ts b/app/components/UI/Perps/hooks/usePerpsDepositStatus.test.ts index 68ed0dd37b4..d13c4c34ce8 100644 --- a/app/components/UI/Perps/hooks/usePerpsDepositStatus.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsDepositStatus.test.ts @@ -149,6 +149,9 @@ describe('usePerpsDepositStatus', () => { hapticsType: NotificationFeedbackType.Success, })), }, + oneClickTrade: { + txCreationFailed: {} as PerpsToastOptions, + }, withdrawal: { withdrawalInProgress: { variant: ToastVariants.Icon, diff --git a/app/components/UI/Perps/hooks/usePerpsNavigation.test.ts b/app/components/UI/Perps/hooks/usePerpsNavigation.test.ts index 799c8ee0e61..773cc6c1af4 100644 --- a/app/components/UI/Perps/hooks/usePerpsNavigation.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsNavigation.test.ts @@ -3,6 +3,8 @@ import { waitFor } from '@testing-library/react-native'; import { useNavigation } from '@react-navigation/native'; import { usePerpsNavigation } from './usePerpsNavigation'; import { usePerpsTrading } from './usePerpsTrading'; +import usePerpsToasts from './usePerpsToasts'; +import { usePerpsEventTracking } from './usePerpsEventTracking'; import Routes from '../../../../constants/navigation/Routes'; jest.mock('@react-navigation/native', () => ({ @@ -10,10 +12,22 @@ jest.mock('@react-navigation/native', () => ({ })); const mockDepositWithOrder = jest.fn(); +const mockShowToast = jest.fn(); +const mockTrack = jest.fn(); + jest.mock('./usePerpsTrading', () => ({ usePerpsTrading: jest.fn(), })); +jest.mock('./usePerpsToasts', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('./usePerpsEventTracking', () => ({ + usePerpsEventTracking: jest.fn(), +})); + describe('usePerpsNavigation', () => { const mockNavigate = jest.fn(); const mockCanGoBack = jest.fn(); @@ -24,6 +38,11 @@ describe('usePerpsNavigation', () => { const mockUsePerpsTrading = usePerpsTrading as jest.MockedFunction< typeof usePerpsTrading >; + const mockUsePerpsToasts = usePerpsToasts as jest.MockedFunction< + typeof usePerpsToasts + >; + const mockUsePerpsEventTracking = + usePerpsEventTracking as jest.MockedFunction; beforeEach(() => { jest.clearAllMocks(); @@ -34,6 +53,18 @@ describe('usePerpsNavigation', () => { } as Partial> as ReturnType< typeof usePerpsTrading >); + mockUsePerpsToasts.mockReturnValue({ + showToast: mockShowToast, + PerpsToastOptions: { + accountManagement: { + deposit: { error: {} }, + oneClickTrade: { txCreationFailed: {} }, + }, + }, + } as unknown as ReturnType); + mockUsePerpsEventTracking.mockReturnValue({ + track: mockTrack, + }); mockUseNavigation.mockReturnValue({ navigate: mockNavigate, canGoBack: mockCanGoBack, @@ -207,6 +238,8 @@ describe('usePerpsNavigation', () => { }); expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockShowToast).toHaveBeenCalledWith({}); + expect(mockTrack).toHaveBeenCalled(); }); it('navigates to tutorial without params', () => { diff --git a/app/components/UI/Perps/hooks/usePerpsNavigation.ts b/app/components/UI/Perps/hooks/usePerpsNavigation.ts index 92b1a7becde..69f54b5128d 100644 --- a/app/components/UI/Perps/hooks/usePerpsNavigation.ts +++ b/app/components/UI/Perps/hooks/usePerpsNavigation.ts @@ -4,6 +4,13 @@ import Routes from '../../../../constants/navigation/Routes'; import type { PerpsNavigationParamList } from '../types/navigation'; import type { PerpsMarketData, Position, Order } from '../controllers/types'; import { usePerpsTrading } from './usePerpsTrading'; +import usePerpsToasts from './usePerpsToasts'; +import { usePerpsEventTracking } from './usePerpsEventTracking'; +import { + PerpsEventProperties, + PerpsEventValues, +} from '../constants/eventNames'; +import { MetaMetricsEvents } from '../../../hooks/useMetrics'; import Logger from '../../../../util/Logger'; import { ensureError } from '../../../../util/errorUtils'; import { PERPS_CONSTANTS } from '../constants/perpsConfig'; @@ -129,6 +136,8 @@ export const usePerpsNavigation = (): PerpsNavigationHandlers => { ); const { depositWithOrder } = usePerpsTrading(); + const { showToast, PerpsToastOptions } = usePerpsToasts(); + const { track } = usePerpsEventTracking(); const navigateToOrder = useCallback( (params: PerpsNavigationParamList['PerpsOrder']) => { @@ -140,14 +149,32 @@ export const usePerpsNavigation = (): PerpsNavigationHandlers => { ); }) .catch((error: unknown) => { - Logger.error(ensureError(error), { + const err = ensureError(error); + Logger.error(err, { feature: PERPS_CONSTANTS.FeatureName, message: 'Failed to start one-click trade (deposit rejected or failed)', }); + + track(MetaMetricsEvents.PERPS_ERROR, { + [PerpsEventProperties.ERROR_TYPE]: + PerpsEventValues.ERROR_TYPE.BACKEND, + [PerpsEventProperties.ERROR_MESSAGE]: err.message, + [PerpsEventProperties.SOURCE]: PerpsEventValues.SOURCE.TRADE_ACTION, + }); + + showToast( + PerpsToastOptions.accountManagement.oneClickTrade.txCreationFailed, + ); }); }, - [navigation, depositWithOrder], + [ + navigation, + depositWithOrder, + showToast, + PerpsToastOptions.accountManagement.oneClickTrade.txCreationFailed, + track, + ], ); const navigateToTutorial = useCallback( diff --git a/app/components/UI/Perps/hooks/usePerpsToasts.tsx b/app/components/UI/Perps/hooks/usePerpsToasts.tsx index a705b2faa0b..75b32adbdca 100644 --- a/app/components/UI/Perps/hooks/usePerpsToasts.tsx +++ b/app/components/UI/Perps/hooks/usePerpsToasts.tsx @@ -47,6 +47,9 @@ export interface PerpsToastOptionsConfig { ) => PerpsToastOptions; error: PerpsToastOptions; }; + oneClickTrade: { + txCreationFailed: PerpsToastOptions; + }; withdrawal: { withdrawalInProgress: PerpsToastOptions; withdrawalSuccess: ( @@ -399,6 +402,15 @@ const usePerpsToasts = (): { ), }, }, + oneClickTrade: { + txCreationFailed: { + ...perpsBaseToastOptions.error, + labelOptions: getPerpsToastLabels( + strings('perps.one_click_trade.tx_creation_failed_title'), + strings('perps.one_click_trade.tx_creation_failed_description'), + ), + }, + }, withdrawal: { withdrawalInProgress: { ...perpsBaseToastOptions.inProgress, diff --git a/app/components/UI/Perps/providers/PerpsConnectionProvider.tsx b/app/components/UI/Perps/providers/PerpsConnectionProvider.tsx index 35b407bf07a..c5e76702f80 100644 --- a/app/components/UI/Perps/providers/PerpsConnectionProvider.tsx +++ b/app/components/UI/Perps/providers/PerpsConnectionProvider.tsx @@ -100,7 +100,7 @@ export const PerpsConnectionProvider: React.FC< try { await PerpsConnectionManager.connect(); } catch (err) { - Logger.error(err as Error, { + Logger.error(ensureError(err, 'PerpsConnectionProvider.connect'), { message: 'PerpsConnectionProvider: Error during connect', context: 'PerpsConnectionProvider.connect', }); @@ -128,7 +128,7 @@ export const PerpsConnectionProvider: React.FC< try { await PerpsConnectionManager.disconnect(); } catch (err) { - Logger.error(err as Error, { + Logger.error(ensureError(err, 'PerpsConnectionProvider.disconnect'), { message: 'PerpsConnectionProvider: Error during disconnect', context: 'PerpsConnectionProvider.disconnect', }); @@ -166,10 +166,13 @@ export const PerpsConnectionProvider: React.FC< // Use the existing reconnectWithNewContext method from the singleton await PerpsConnectionManager.reconnectWithNewContext(options); } catch (err) { - Logger.error(err as Error, { - message: 'PerpsConnectionProvider: Error during reconnect', - context: 'PerpsConnectionProvider.reconnectWithNewContext', - }); + Logger.error( + ensureError(err, 'PerpsConnectionProvider.reconnectWithNewContext'), + { + message: 'PerpsConnectionProvider: Error during reconnect', + context: 'PerpsConnectionProvider.reconnectWithNewContext', + }, + ); } // Always update state after reconnection attempt const state = PerpsConnectionManager.getConnectionState(); @@ -185,11 +188,14 @@ export const PerpsConnectionProvider: React.FC< try { await PerpsConnectionManager.connect(); } catch (err) { - Logger.error(err as Error, { - message: 'PerpsConnectionProvider: Error in lifecycle onConnect', - context: - 'PerpsConnectionProvider.usePerpsConnectionLifecycle.onConnect', - }); + Logger.error( + ensureError(err, 'PerpsConnectionProvider.lifecycle.onConnect'), + { + message: 'PerpsConnectionProvider: Error in lifecycle onConnect', + context: + 'PerpsConnectionProvider.usePerpsConnectionLifecycle.onConnect', + }, + ); } const state = PerpsConnectionManager.getConnectionState(); setConnectionState(state); @@ -198,11 +204,14 @@ export const PerpsConnectionProvider: React.FC< try { await PerpsConnectionManager.disconnect(); } catch (err) { - Logger.error(err as Error, { - message: 'PerpsConnectionProvider: Error in lifecycle onDisconnect', - context: - 'PerpsConnectionProvider.usePerpsConnectionLifecycle.onDisconnect', - }); + Logger.error( + ensureError(err, 'PerpsConnectionProvider.lifecycle.onDisconnect'), + { + message: 'PerpsConnectionProvider: Error in lifecycle onDisconnect', + context: + 'PerpsConnectionProvider.usePerpsConnectionLifecycle.onDisconnect', + }, + ); } const state = PerpsConnectionManager.getConnectionState(); setConnectionState(state); diff --git a/app/components/UI/Perps/providers/PerpsStreamManager.tsx b/app/components/UI/Perps/providers/PerpsStreamManager.tsx index c444146abf6..0cc19de3876 100644 --- a/app/components/UI/Perps/providers/PerpsStreamManager.tsx +++ b/app/components/UI/Perps/providers/PerpsStreamManager.tsx @@ -4,6 +4,7 @@ import performance from 'react-native-performance'; import Engine from '../../../../core/Engine'; import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; import Logger from '../../../../util/Logger'; +import { ensureError } from '../../../../util/errorUtils'; import { trace, endTrace, @@ -410,7 +411,7 @@ class PriceStreamChannel extends StreamChannel> { this.cleanupPrewarm(); }; } catch (error) { - Logger.error(error instanceof Error ? error : new Error(String(error)), { + Logger.error(ensureError(error, 'PriceStreamChannel.prewarm'), { context: 'PriceStreamChannel.prewarm', }); // Return no-op cleanup function @@ -1181,7 +1182,7 @@ class MarketDataChannel extends StreamChannel { // Don't await - just trigger the fetch and handle errors this.fetchMarketData().catch((error) => { Logger.error( - error instanceof Error ? error : new Error(String(error)), + ensureError(error, 'PerpsStreamManager.fetchMarketData.background'), 'PerpsStreamManager: Failed to fetch market data', ); }); @@ -1238,13 +1239,10 @@ class MarketDataChannel extends StreamChannel { }); } catch (error) { const fetchTime = Date.now() - fetchStartTime; - Logger.error( - error instanceof Error ? error : new Error(String(error)), - { - context: 'PerpsStreamManager.fetchMarketData', - fetchTimeMs: fetchTime, - }, - ); + Logger.error(ensureError(error, 'PerpsStreamManager.fetchMarketData'), { + context: 'PerpsStreamManager.fetchMarketData', + fetchTimeMs: fetchTime, + }); // Keep existing cache if fetch fails const existing = this.cache.get('markets'); if (existing) { diff --git a/app/components/UI/Perps/services/HyperLiquidClientService.ts b/app/components/UI/Perps/services/HyperLiquidClientService.ts index 33c55ebaa32..7abf33c7571 100644 --- a/app/components/UI/Perps/services/HyperLiquidClientService.ts +++ b/app/components/UI/Perps/services/HyperLiquidClientService.ts @@ -526,6 +526,9 @@ export class HyperLiquidClientService { let currentCandleData: CandleData | null = null; let wsUnsubscribe: (() => void) | null = null; let isUnsubscribed = false; + // Store the subscription promise to enable cleanup even when pending + // This fixes a race condition where component unmounts before subscription resolves + let subscriptionPromise: Promise<{ unsubscribe: () => void }> | null = null; // Calculate initial fetch size dynamically based on duration and interval // Match main branch behavior: up to 500 candles initially @@ -548,7 +551,8 @@ export class HyperLiquidClientService { // 2. Subscribe to WebSocket for new candles // HyperLiquid SDK uses 'coin' terminology - const subscription = subscriptionClient.candle( + // Store the promise so cleanup can wait for it if needed + subscriptionPromise = subscriptionClient.candle( { coin: symbol, interval }, // Map to HyperLiquid SDK's 'coin' parameter (candleEvent) => { // Don't process events if already unsubscribed @@ -598,12 +602,12 @@ export class HyperLiquidClientService { }, ); - // Store cleanup function - subscription + // Store cleanup function when subscription resolves + subscriptionPromise .then((sub) => { wsUnsubscribe = () => sub.unsubscribe(); // If already unsubscribed while waiting, clean up immediately - if (isUnsubscribed && wsUnsubscribe) { + if (isUnsubscribed) { wsUnsubscribe(); wsUnsubscribe = null; } @@ -663,8 +667,19 @@ export class HyperLiquidClientService { return () => { isUnsubscribed = true; if (wsUnsubscribe) { + // Subscription already resolved - unsubscribe directly wsUnsubscribe(); wsUnsubscribe = null; + } else if (subscriptionPromise) { + // Subscription promise still pending - wait for it and clean up + // This prevents WebSocket subscription leaks when component unmounts + // before the subscription promise resolves + subscriptionPromise + .then((sub) => sub.unsubscribe()) + .catch(() => { + // Ignore errors during cleanup - subscription may have failed + }); + subscriptionPromise = null; } }; } diff --git a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts index 34ad6297352..cda9e4f7a40 100644 --- a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts +++ b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts @@ -158,6 +158,18 @@ export class HyperLiquidSubscriptionService { >(); // Key: dex name ('' for main) private readonly openOrdersSubscriptions = new Map(); // Key: dex name ('' for main) + // Pending subscription promises to prevent race conditions + // When multiple calls to ensure*Subscription happen concurrently, this ensures + // only one subscription is created per DEX (others wait for the pending promise) + private readonly pendingClearinghouseSubscriptions = new Map< + string, + Promise + >(); + private readonly pendingOpenOrdersSubscriptions = new Map< + string, + Promise + >(); + // Meta cache per DEX - populated by metaAndAssetCtxs, used by createAssetCtxsSubscription // This avoids redundant meta() API calls since metaAndAssetCtxs already returns meta data private readonly dexMetaCache = new Map< @@ -1258,15 +1270,49 @@ export class HyperLiquidSubscriptionService { /** * Ensure clearinghouseState subscription exists for a DEX + * Uses pending promise tracking to prevent race conditions where multiple + * concurrent calls could create duplicate subscriptions */ private async ensureClearinghouseStateSubscription( userAddress: string, dexName: string, ): Promise { + // Already subscribed if (this.clearinghouseStateSubscriptions.has(dexName)) { - return; // Already subscribed + return; + } + + // Another call is already in progress - wait for it instead of creating duplicate + const pending = this.pendingClearinghouseSubscriptions.get(dexName); + if (pending) { + this.deps.debugLogger.log( + `[ensureClearinghouseStateSubscription] Waiting for pending subscription for DEX: ${dexName || 'main'}`, + ); + return pending; } + // Create subscription promise and track it + const subscriptionPromise = this.createClearinghouseSubscription( + userAddress, + dexName, + ); + this.pendingClearinghouseSubscriptions.set(dexName, subscriptionPromise); + + try { + await subscriptionPromise; + } finally { + this.pendingClearinghouseSubscriptions.delete(dexName); + } + } + + /** + * Create the actual clearinghouseState subscription + * Separated from ensureClearinghouseStateSubscription to enable promise deduplication + */ + private async createClearinghouseSubscription( + userAddress: string, + dexName: string, + ): Promise { const subscriptionClient = this.clientService.getSubscriptionClient(); if (!subscriptionClient) { throw new Error('Subscription client not available'); @@ -1351,15 +1397,49 @@ export class HyperLiquidSubscriptionService { /** * Ensure openOrders subscription exists for a DEX + * Uses pending promise tracking to prevent race conditions where multiple + * concurrent calls could create duplicate subscriptions */ private async ensureOpenOrdersSubscription( userAddress: string, dexName: string, ): Promise { + // Already subscribed if (this.openOrdersSubscriptions.has(dexName)) { - return; // Already subscribed + return; + } + + // Another call is already in progress - wait for it instead of creating duplicate + const pending = this.pendingOpenOrdersSubscriptions.get(dexName); + if (pending) { + this.deps.debugLogger.log( + `[ensureOpenOrdersSubscription] Waiting for pending subscription for DEX: ${dexName || 'main'}`, + ); + return pending; + } + + // Create subscription promise and track it + const subscriptionPromise = this.createOpenOrdersSubscription( + userAddress, + dexName, + ); + this.pendingOpenOrdersSubscriptions.set(dexName, subscriptionPromise); + + try { + await subscriptionPromise; + } finally { + this.pendingOpenOrdersSubscriptions.delete(dexName); } + } + /** + * Create the actual openOrders subscription + * Separated from ensureOpenOrdersSubscription to enable promise deduplication + */ + private async createOpenOrdersSubscription( + userAddress: string, + dexName: string, + ): Promise { const subscriptionClient = this.clientService.getSubscriptionClient(); if (!subscriptionClient) { throw new Error('Subscription client not available'); @@ -1569,6 +1649,10 @@ export class HyperLiquidSubscriptionService { this.openOrdersSubscriptions.clear(); } + // Clear pending subscription promises (race condition prevention) + this.pendingClearinghouseSubscriptions.clear(); + this.pendingOpenOrdersSubscriptions.clear(); + // Clear subscriber counts this.positionSubscriberCount = 0; this.orderSubscriberCount = 0; diff --git a/app/components/UI/Perps/services/PerpsConnectionManager.ts b/app/components/UI/Perps/services/PerpsConnectionManager.ts index a1874c4e97a..3fd3358e897 100644 --- a/app/components/UI/Perps/services/PerpsConnectionManager.ts +++ b/app/components/UI/Perps/services/PerpsConnectionManager.ts @@ -559,36 +559,31 @@ class PerpsConnectionManagerClass { this.clearConnectionTimeout(); // Capture exception with connection context - captureException( - error instanceof Error ? error : new Error(String(error)), - { - tags: { - component: 'PerpsConnectionManager', - action: 'connection_connection', - operation: 'connection_management', + captureException(ensureError(error, 'PerpsConnectionManager.connect'), { + tags: { + component: 'PerpsConnectionManager', + action: 'connection_connection', + operation: 'connection_management', + provider: 'hyperliquid', + }, + extra: { + connectionContext: { provider: 'hyperliquid', - }, - extra: { - connectionContext: { - provider: 'hyperliquid', - timestamp: new Date().toISOString(), - isTestnet: - Engine.context.PerpsController?.getCurrentNetwork?.() === - 'testnet', - }, + timestamp: new Date().toISOString(), + isTestnet: + Engine.context.PerpsController?.getCurrentNetwork?.() === + 'testnet', }, }, - ); + }); traceData = { success: false, - error: error instanceof Error ? error.message : 'Unknown error', + error: ensureError(error, 'PerpsConnectionManager.connect').message, }; // Set error state for UI - this.setError( - error instanceof Error ? error : new Error(String(error)), - ); + this.setError(ensureError(error, 'PerpsConnectionManager.connect')); DevLogger.log('PerpsConnectionManager: Connection failed', error); throw error; } finally { @@ -805,11 +800,11 @@ class PerpsConnectionManagerClass { traceData = { success: false, - error: error instanceof Error ? error.message : 'Unknown error', + error: ensureError(error, 'PerpsConnectionManager.reconnect').message, }; // Set error state for UI - this is critical for reliability - this.setError(error instanceof Error ? error : new Error(String(error))); + this.setError(ensureError(error, 'PerpsConnectionManager.reconnect')); DevLogger.log( 'PerpsConnectionManager: Reconnection with new context failed', error, diff --git a/app/components/UI/Perps/types/hyperliquid-types.ts b/app/components/UI/Perps/types/hyperliquid-types.ts index e1384805144..d18f9d1cb20 100644 --- a/app/components/UI/Perps/types/hyperliquid-types.ts +++ b/app/components/UI/Perps/types/hyperliquid-types.ts @@ -16,6 +16,7 @@ import type { AllMidsResponse, PredictedFundingsResponse, OrderParameters, + SpotMetaResponse, } from '@nktkas/hyperliquid'; // Clearinghouse (Account) Types @@ -42,4 +43,5 @@ export type { AllMidsResponse, MetaAndAssetCtxsResponse, PredictedFundingsResponse, + SpotMetaResponse, }; diff --git a/app/components/UI/Ramp/Aggregator/Views/Settings/Settings.test.tsx b/app/components/UI/Ramp/Aggregator/Views/Settings/Settings.test.tsx index ed39548e693..64fc6daadaf 100644 --- a/app/components/UI/Ramp/Aggregator/Views/Settings/Settings.test.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/Settings/Settings.test.tsx @@ -86,10 +86,7 @@ jest.mock('../../hooks/useActivationKeys', () => const mockSetUserRegion = jest.fn(); const mockFetchUserRegion = jest.fn(); -const mockFetchProviders = jest.fn(); -const mockFetchTokens = jest.fn(); -const mockFetchCountries = jest.fn(); -const mockSetPreferredProvider = jest.fn(); +const mockSetSelectedProvider = jest.fn(); const createMockUserRegion = (regionCode: string): UserRegion => { const parts = regionCode.toLowerCase().split('-'); @@ -103,13 +100,13 @@ const createMockUserRegion = (regionCode: string): UserRegion => { name: 'Europe Union', phone: { prefix: '', placeholder: '', template: '' }, currency: '', - supported: true, + supported: { buy: true, sell: true }, }, state: stateCode ? { stateId: stateCode, name: stateCode, - supported: true, + supported: { buy: true, sell: true }, } : null, regionCode: regionCode.toLowerCase(), @@ -124,20 +121,24 @@ const mockUseRampsControllerInitialValues: ReturnType< userRegionError: null, setUserRegion: mockSetUserRegion, fetchUserRegion: mockFetchUserRegion, - preferredProvider: null, - setPreferredProvider: mockSetPreferredProvider, + selectedProvider: null, + setSelectedProvider: mockSetSelectedProvider, providers: [], providersLoading: false, providersError: null, - fetchProviders: mockFetchProviders, tokens: null, + selectedToken: null, + setSelectedToken: jest.fn(), tokensLoading: false, tokensError: null, - fetchTokens: mockFetchTokens, - countries: null, + countries: [], countriesLoading: false, countriesError: null, - fetchCountries: mockFetchCountries, + paymentMethods: [], + selectedPaymentMethod: null, + setSelectedPaymentMethod: jest.fn(), + paymentMethodsLoading: false, + paymentMethodsError: null, }; let mockUseRampsControllerValues = mockUseRampsControllerInitialValues; diff --git a/app/components/UI/Ramp/components/BuildQuote/BuildQuote.test.tsx b/app/components/UI/Ramp/components/BuildQuote/BuildQuote.test.tsx index d182b87c063..9e10f79d281 100644 --- a/app/components/UI/Ramp/components/BuildQuote/BuildQuote.test.tsx +++ b/app/components/UI/Ramp/components/BuildQuote/BuildQuote.test.tsx @@ -84,7 +84,7 @@ const defaultUserRegion: MockUserRegion = { }; let mockUserRegion: MockUserRegion | null = defaultUserRegion; -let mockPreferredProvider: unknown = null; +let mockSelectedProvider: unknown = null; let mockTokens: { allTokens: ReturnType[]; topTokens: ReturnType[]; @@ -96,7 +96,7 @@ let mockTokens: { jest.mock('../../hooks/useRampsController', () => ({ useRampsController: () => ({ userRegion: mockUserRegion, - preferredProvider: mockPreferredProvider, + selectedProvider: mockSelectedProvider, tokens: mockTokens, }), })); @@ -112,7 +112,7 @@ describe('BuildQuote', () => { beforeEach(() => { jest.clearAllMocks(); mockUserRegion = defaultUserRegion; - mockPreferredProvider = null; + mockSelectedProvider = null; mockTokens = { allTokens: [createMockToken()], topTokens: [createMockToken()], @@ -277,8 +277,8 @@ describe('BuildQuote', () => { expect(getByTestId('build-quote-continue-button')).toBeOnTheScreen(); }); - it('displays powered by provider text when preferred provider is set', () => { - mockPreferredProvider = { + it('displays powered by provider text when selected provider is set', () => { + mockSelectedProvider = { id: '/providers/transak', name: 'Transak', environmentType: 'PRODUCTION', @@ -293,8 +293,8 @@ describe('BuildQuote', () => { expect(getByText('fiat_on_ramp.powered_by_provider')).toBeOnTheScreen(); }); - it('does not display powered by text when no preferred provider is set', () => { - mockPreferredProvider = null; + it('does not display powered by text when no selected provider is set', () => { + mockSelectedProvider = null; const { queryByText } = renderWithTheme(); diff --git a/app/components/UI/Ramp/components/BuildQuote/BuildQuote.tsx b/app/components/UI/Ramp/components/BuildQuote/BuildQuote.tsx index 1facf50ddc8..4db68438fa4 100644 --- a/app/components/UI/Ramp/components/BuildQuote/BuildQuote.tsx +++ b/app/components/UI/Ramp/components/BuildQuote/BuildQuote.tsx @@ -45,8 +45,8 @@ function BuildQuote() { const [amount, setAmount] = useState('0'); const [amountAsNumber, setAmountAsNumber] = useState(0); - // Get user region, preferred provider, and tokens from RampsController - const { userRegion, preferredProvider, tokens } = useRampsController(); + // Get user region, selected provider, and tokens from RampsController + const { userRegion, selectedProvider, tokens } = useRampsController(); // Get currency and quick amounts from user's region const currency = userRegion?.country?.currency || 'USD'; @@ -128,10 +128,10 @@ function BuildQuote() { - {preferredProvider && ( + {selectedProvider && ( {strings('fiat_on_ramp.powered_by_provider', { - provider: preferredProvider.name, + provider: selectedProvider.name, })} )} diff --git a/app/components/UI/Ramp/components/Modals/SettingsModal/SettingsModal.test.tsx b/app/components/UI/Ramp/components/Modals/SettingsModal/SettingsModal.test.tsx index 996f0afc88a..9c4cdd8058d 100644 --- a/app/components/UI/Ramp/components/Modals/SettingsModal/SettingsModal.test.tsx +++ b/app/components/UI/Ramp/components/Modals/SettingsModal/SettingsModal.test.tsx @@ -101,13 +101,13 @@ jest.mock('react-native', () => { }; }); -let mockPreferredProvider: Provider | null = createMockProvider(); -const mockSetPreferredProvider = jest.fn(); +let mockSelectedProvider: Provider | null = createMockProvider(); +const mockSetSelectedProvider = jest.fn(); jest.mock('../../../hooks/useRampsController', () => ({ useRampsController: () => ({ - preferredProvider: mockPreferredProvider, - setPreferredProvider: mockSetPreferredProvider, + selectedProvider: mockSelectedProvider, + setSelectedProvider: mockSetSelectedProvider, }), })); @@ -152,7 +152,7 @@ function renderWithProvider(component: React.ComponentType) { describe('SettingsModal', () => { beforeEach(() => { jest.clearAllMocks(); - mockPreferredProvider = createMockProvider(); + mockSelectedProvider = createMockProvider(); mockGetProviderToken.mockResolvedValue({ success: false, error: 'No token found', @@ -208,7 +208,7 @@ describe('SettingsModal', () => { }); it('hides contact support when provider has no support URL', () => { - mockPreferredProvider = createMockProvider({ links: [] }); + mockSelectedProvider = createMockProvider({ links: [] }); const { queryByText } = renderWithProvider(SettingsModal); @@ -218,7 +218,7 @@ describe('SettingsModal', () => { describe('logout (Transak only)', () => { beforeEach(() => { - mockPreferredProvider = createMockTransakProvider(); + mockSelectedProvider = createMockTransakProvider(); mockGetProviderToken.mockResolvedValue({ success: true, token: { @@ -251,7 +251,7 @@ describe('SettingsModal', () => { expect(mockResetProviderToken).toHaveBeenCalled(); }); - expect(mockSetPreferredProvider).toHaveBeenCalledWith(null); + expect(mockSetSelectedProvider).toHaveBeenCalledWith(null); expect(mockShowToast).toHaveBeenCalledWith({ variant: 'Icon', labelOptions: [{ label: 'Successfully logged out' }], @@ -286,7 +286,7 @@ describe('SettingsModal', () => { }); it('hides logout option for non-Transak providers even when authenticated', async () => { - mockPreferredProvider = createMockProvider(); + mockSelectedProvider = createMockProvider(); const { queryByText } = renderWithProvider(SettingsModal); @@ -298,7 +298,7 @@ describe('SettingsModal', () => { describe('when user is not authenticated', () => { beforeEach(() => { - mockPreferredProvider = createMockTransakProvider(); + mockSelectedProvider = createMockTransakProvider(); mockGetProviderToken.mockResolvedValue({ success: false, error: 'No token found', @@ -316,7 +316,7 @@ describe('SettingsModal', () => { describe('when no preferred provider is set', () => { beforeEach(() => { - mockPreferredProvider = null; + mockSelectedProvider = null; }); it('hides contact support option', () => { diff --git a/app/components/UI/Ramp/components/Modals/SettingsModal/SettingsModal.tsx b/app/components/UI/Ramp/components/Modals/SettingsModal/SettingsModal.tsx index 1a50ffbc396..5a578cb41fe 100644 --- a/app/components/UI/Ramp/components/Modals/SettingsModal/SettingsModal.tsx +++ b/app/components/UI/Ramp/components/Modals/SettingsModal/SettingsModal.tsx @@ -45,7 +45,7 @@ function SettingsModal() { const sheetRef = useRef(null); const navigation = useNavigation(); const { toastRef } = useContext(ToastContext); - const { preferredProvider, setPreferredProvider } = useRampsController(); + const { selectedProvider, setSelectedProvider } = useRampsController(); const [isAuthenticatedWithProvider, setIsAuthenticatedWithProvider] = useState(false); @@ -55,7 +55,7 @@ function SettingsModal() { const checkAuthentication = async () => { // Only Transak supports native authentication/logout - if (preferredProvider?.id !== TRANSAK_PROVIDER_ID) { + if (selectedProvider?.id !== TRANSAK_PROVIDER_ID) { if (isMounted) { setIsAuthenticatedWithProvider(false); } @@ -81,9 +81,9 @@ function SettingsModal() { return () => { isMounted = false; }; - }, [preferredProvider?.id]); + }, [selectedProvider?.id]); - const supportUrl = preferredProvider?.links?.find( + const supportUrl = selectedProvider?.links?.find( (link) => link.name === PROVIDER_LINKS.SUPPORT, )?.url; @@ -107,7 +107,7 @@ function SettingsModal() { const handleLogOut = useCallback(async () => { try { await resetProviderToken(); - setPreferredProvider(null); + setSelectedProvider(null); sheetRef.current?.onCloseBottomSheet(); toastRef?.current?.showToast({ @@ -139,7 +139,7 @@ function SettingsModal() { hasNoTimeout: false, }); } - }, [setPreferredProvider, toastRef]); + }, [setSelectedProvider, toastRef]); const handleClosePress = useCallback(() => { sheetRef.current?.onCloseBottomSheet(); @@ -168,11 +168,11 @@ function SettingsModal() { /> )} - {isAuthenticatedWithProvider && preferredProvider && ( + {isAuthenticatedWithProvider && selectedProvider && ( diff --git a/app/components/UI/Ramp/components/TokenSelection/TokenSelection.test.tsx b/app/components/UI/Ramp/components/TokenSelection/TokenSelection.test.tsx index 00addc44986..5e4779323af 100644 --- a/app/components/UI/Ramp/components/TokenSelection/TokenSelection.test.tsx +++ b/app/components/UI/Ramp/components/TokenSelection/TokenSelection.test.tsx @@ -152,6 +152,8 @@ describe('TokenSelection Component', () => { topTokens: rampsTokens, allTokens: rampsTokens, }, + selectedToken: null, + setSelectedToken: jest.fn(), tokensLoading: false, tokensError: null, userRegion: null, @@ -159,17 +161,19 @@ describe('TokenSelection Component', () => { userRegionError: null, fetchUserRegion: jest.fn(), setUserRegion: jest.fn(), - preferredProvider: null, - setPreferredProvider: jest.fn(), + selectedProvider: null, + setSelectedProvider: jest.fn(), providers: [], providersLoading: false, providersError: null, - fetchProviders: jest.fn(), - fetchTokens: jest.fn(), - countries: null, + countries: [], countriesLoading: false, countriesError: null, - fetchCountries: jest.fn(), + paymentMethods: [], + selectedPaymentMethod: null, + setSelectedPaymentMethod: jest.fn(), + paymentMethodsLoading: false, + paymentMethodsError: null, }); }); @@ -276,6 +280,8 @@ describe('TokenSelection Component', () => { mockUseRampsUnifiedV2Enabled.mockReturnValue(true); mockUseRampsController.mockReturnValue({ tokens: null, + selectedToken: null, + setSelectedToken: jest.fn(), tokensLoading: true, tokensError: null, userRegion: null, @@ -283,17 +289,19 @@ describe('TokenSelection Component', () => { userRegionError: null, fetchUserRegion: jest.fn(), setUserRegion: jest.fn(), - preferredProvider: null, - setPreferredProvider: jest.fn(), + selectedProvider: null, + setSelectedProvider: jest.fn(), providers: [], providersLoading: false, providersError: null, - fetchProviders: jest.fn(), - fetchTokens: jest.fn(), - countries: null, + countries: [], countriesLoading: false, countriesError: null, - fetchCountries: jest.fn(), + paymentMethods: [], + selectedPaymentMethod: null, + setSelectedPaymentMethod: jest.fn(), + paymentMethodsLoading: false, + paymentMethodsError: null, }); const { UNSAFE_getByType } = renderWithProvider(TokenSelection); @@ -320,6 +328,8 @@ describe('TokenSelection Component', () => { mockUseRampsUnifiedV2Enabled.mockReturnValue(true); mockUseRampsController.mockReturnValue({ tokens: null, + selectedToken: null, + setSelectedToken: jest.fn(), tokensLoading: false, tokensError: 'Network error', userRegion: null, @@ -327,17 +337,19 @@ describe('TokenSelection Component', () => { userRegionError: null, fetchUserRegion: jest.fn(), setUserRegion: jest.fn(), - preferredProvider: null, - setPreferredProvider: jest.fn(), + selectedProvider: null, + setSelectedProvider: jest.fn(), providers: [], providersLoading: false, providersError: null, - fetchProviders: jest.fn(), - fetchTokens: jest.fn(), - countries: null, + countries: [], countriesLoading: false, countriesError: null, - fetchCountries: jest.fn(), + paymentMethods: [], + selectedPaymentMethod: null, + setSelectedPaymentMethod: jest.fn(), + paymentMethodsLoading: false, + paymentMethodsError: null, }); const { getByText } = renderWithProvider(TokenSelection); @@ -376,6 +388,8 @@ describe('TokenSelection Component', () => { topTokens, allTokens, }, + selectedToken: null, + setSelectedToken: jest.fn(), tokensLoading: false, tokensError: null, userRegion: null, @@ -383,17 +397,19 @@ describe('TokenSelection Component', () => { userRegionError: null, fetchUserRegion: jest.fn(), setUserRegion: jest.fn(), - preferredProvider: null, - setPreferredProvider: jest.fn(), + selectedProvider: null, + setSelectedProvider: jest.fn(), providers: [], providersLoading: false, providersError: null, - fetchProviders: jest.fn(), - fetchTokens: jest.fn(), - countries: null, + countries: [], countriesLoading: false, countriesError: null, - fetchCountries: jest.fn(), + paymentMethods: [], + selectedPaymentMethod: null, + setSelectedPaymentMethod: jest.fn(), + paymentMethodsLoading: false, + paymentMethodsError: null, }); renderWithProvider(TokenSelection); @@ -442,6 +458,8 @@ describe('TokenSelection Component', () => { topTokens, allTokens, }, + selectedToken: null, + setSelectedToken: jest.fn(), tokensLoading: false, tokensError: null, userRegion: null, @@ -449,17 +467,19 @@ describe('TokenSelection Component', () => { userRegionError: null, fetchUserRegion: jest.fn(), setUserRegion: jest.fn(), - preferredProvider: null, - setPreferredProvider: jest.fn(), + selectedProvider: null, + setSelectedProvider: jest.fn(), providers: [], providersLoading: false, providersError: null, - fetchProviders: jest.fn(), - fetchTokens: jest.fn(), - countries: null, + countries: [], countriesLoading: false, countriesError: null, - fetchCountries: jest.fn(), + paymentMethods: [], + selectedPaymentMethod: null, + setSelectedPaymentMethod: jest.fn(), + paymentMethodsLoading: false, + paymentMethodsError: null, }); const { getByPlaceholderText } = renderWithProvider(TokenSelection); @@ -512,6 +532,8 @@ describe('TokenSelection Component', () => { topTokens, allTokens, }, + selectedToken: null, + setSelectedToken: jest.fn(), tokensLoading: false, tokensError: null, userRegion: null, @@ -519,17 +541,19 @@ describe('TokenSelection Component', () => { userRegionError: null, fetchUserRegion: jest.fn(), setUserRegion: jest.fn(), - preferredProvider: null, - setPreferredProvider: jest.fn(), + selectedProvider: null, + setSelectedProvider: jest.fn(), providers: [], providersLoading: false, providersError: null, - fetchProviders: jest.fn(), - fetchTokens: jest.fn(), - countries: null, + countries: [], countriesLoading: false, countriesError: null, - fetchCountries: jest.fn(), + paymentMethods: [], + selectedPaymentMethod: null, + setSelectedPaymentMethod: jest.fn(), + paymentMethodsLoading: false, + paymentMethodsError: null, }); const { getByPlaceholderText } = renderWithProvider(TokenSelection); @@ -584,6 +608,8 @@ describe('TokenSelection Component', () => { topTokens: allTokensWithUnconfiguredNetwork, allTokens: allTokensWithUnconfiguredNetwork, }, + selectedToken: null, + setSelectedToken: jest.fn(), tokensLoading: false, tokensError: null, userRegion: null, @@ -591,17 +617,19 @@ describe('TokenSelection Component', () => { userRegionError: null, fetchUserRegion: jest.fn(), setUserRegion: jest.fn(), - preferredProvider: null, - setPreferredProvider: jest.fn(), + selectedProvider: null, + setSelectedProvider: jest.fn(), providers: [], providersLoading: false, providersError: null, - fetchProviders: jest.fn(), - fetchTokens: jest.fn(), - countries: null, + countries: [], countriesLoading: false, countriesError: null, - fetchCountries: jest.fn(), + paymentMethods: [], + selectedPaymentMethod: null, + setSelectedPaymentMethod: jest.fn(), + paymentMethodsLoading: false, + paymentMethodsError: null, }); renderWithProvider(TokenSelection); diff --git a/app/components/UI/Ramp/hooks/useHydrateRampsController.test.ts b/app/components/UI/Ramp/hooks/useHydrateRampsController.test.ts new file mode 100644 index 00000000000..c3cbf258d6d --- /dev/null +++ b/app/components/UI/Ramp/hooks/useHydrateRampsController.test.ts @@ -0,0 +1,124 @@ +import { renderHook } from '@testing-library/react-native'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import React from 'react'; +import { useHydrateRampsController } from './useHydrateRampsController'; +import Engine from '../../../../core/Engine'; + +jest.mock('../../../../core/Engine', () => ({ + context: { + RampsController: { + hydrateState: jest.fn().mockResolvedValue(undefined), + }, + }, +})); + +const mockUseRampsUnifiedV2Enabled = jest.fn(); +jest.mock('./useRampsUnifiedV2Enabled', () => ({ + __esModule: true, + default: () => mockUseRampsUnifiedV2Enabled(), +})); + +const createMockStore = (userRegion: { regionCode?: string } | null = null) => + configureStore({ + reducer: { + engine: () => ({ + backgroundState: { + RampsController: { + userRegion, + }, + }, + }), + }, + }); + +const wrapper = (store: ReturnType) => + function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(Provider, { store } as never, children); + }; + +describe('useHydrateRampsController', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseRampsUnifiedV2Enabled.mockReturnValue(true); + }); + + it('calls hydrateState when V2 unified is enabled and userRegion has regionCode', () => { + const store = createMockStore({ regionCode: 'us-ca' }); + renderHook(() => useHydrateRampsController(), { + wrapper: wrapper(store), + }); + + expect(Engine.context.RampsController.hydrateState).toHaveBeenCalledTimes( + 1, + ); + }); + + it('does not call hydrateState when userRegion is null', () => { + const store = createMockStore(null); + renderHook(() => useHydrateRampsController(), { + wrapper: wrapper(store), + }); + + expect(Engine.context.RampsController.hydrateState).not.toHaveBeenCalled(); + }); + + it('does not call hydrateState when userRegion has no regionCode', () => { + const store = createMockStore({}); + renderHook(() => useHydrateRampsController(), { + wrapper: wrapper(store), + }); + + expect(Engine.context.RampsController.hydrateState).not.toHaveBeenCalled(); + }); + + it('calls hydrateState again when regionCode changes', () => { + const store1 = createMockStore({ regionCode: 'us-ca' }); + const { unmount } = renderHook(() => useHydrateRampsController(), { + wrapper: wrapper(store1), + }); + + expect(Engine.context.RampsController.hydrateState).toHaveBeenCalledTimes( + 1, + ); + + unmount(); + + const store2 = createMockStore({ regionCode: 'gb' }); + renderHook(() => useHydrateRampsController(), { + wrapper: wrapper(store2), + }); + + expect(Engine.context.RampsController.hydrateState).toHaveBeenCalledTimes( + 2, + ); + }); + + it('handles hydrateState rejection gracefully', async () => { + ( + Engine.context.RampsController.hydrateState as jest.Mock + ).mockRejectedValueOnce(new Error('Network error')); + + const store = createMockStore({ regionCode: 'us-ca' }); + renderHook(() => useHydrateRampsController(), { + wrapper: wrapper(store), + }); + + await Promise.resolve(); + + expect(Engine.context.RampsController.hydrateState).toHaveBeenCalledTimes( + 1, + ); + }); + + it('does not call hydrateState when V2 unified is disabled', () => { + mockUseRampsUnifiedV2Enabled.mockReturnValue(false); + + const store = createMockStore({ regionCode: 'us-ca' }); + renderHook(() => useHydrateRampsController(), { + wrapper: wrapper(store), + }); + + expect(Engine.context.RampsController.hydrateState).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/UI/Ramp/hooks/useHydrateRampsController.ts b/app/components/UI/Ramp/hooks/useHydrateRampsController.ts new file mode 100644 index 00000000000..9d6c257d59a --- /dev/null +++ b/app/components/UI/Ramp/hooks/useHydrateRampsController.ts @@ -0,0 +1,28 @@ +import { useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import Engine from '../../../../core/Engine'; +import { selectUserRegion } from '../../../../selectors/rampsController'; +import useRampsUnifiedV2Enabled from './useRampsUnifiedV2Enabled'; + +/** + * Hook that hydrates RampsController state (tokens and providers) on mount + * if the user has a saved region and V2 unified is enabled. + * This preloads ramps data for faster UX when the user navigates to buy/sell flows. + * + * Should be called from a top-level component like Wallet that mounts early. + */ +export function useHydrateRampsController(): void { + const userRegion = useSelector(selectUserRegion); + const isV2UnifiedEnabled = useRampsUnifiedV2Enabled(); + + useEffect(() => { + if (isV2UnifiedEnabled && userRegion?.regionCode) { + const { RampsController } = Engine.context; + Promise.resolve(RampsController.hydrateState()).catch(() => { + // Error is stored in state + }); + } + }, [isV2UnifiedEnabled, userRegion?.regionCode]); +} + +export default useHydrateRampsController; diff --git a/app/components/UI/Ramp/hooks/useRampsController.test.ts b/app/components/UI/Ramp/hooks/useRampsController.test.ts index 9f3af9eff26..2f2875e3297 100644 --- a/app/components/UI/Ramp/hooks/useRampsController.test.ts +++ b/app/components/UI/Ramp/hooks/useRampsController.test.ts @@ -6,6 +6,7 @@ import { useRampsController } from './useRampsController'; import { useRampsProviders } from './useRampsProviders'; import { useRampsTokens } from './useRampsTokens'; import { useRampsCountries } from './useRampsCountries'; +import { useRampsPaymentMethods } from './useRampsPaymentMethods'; jest.mock( '../../../../selectors/multichainAccounts/accountTreeController', @@ -27,42 +28,42 @@ jest.mock('./useRampsUserRegion', () => ({ })), })); -jest.mock('./useRampsPreferredProvider', () => ({ - useRampsPreferredProvider: jest.fn(() => ({ - preferredProvider: null, - setPreferredProvider: jest.fn(), - })), -})); - jest.mock('./useRampsProviders', () => ({ useRampsProviders: jest.fn(() => ({ providers: [], + selectedProvider: null, + setSelectedProvider: jest.fn(), isLoading: false, error: null, - fetchProviders: jest.fn(), })), })); jest.mock('./useRampsTokens', () => ({ useRampsTokens: jest.fn(() => ({ tokens: null, + selectedToken: null, + setSelectedToken: jest.fn(), isLoading: false, error: null, - fetchTokens: jest.fn(), })), })); jest.mock('./useRampsCountries', () => ({ useRampsCountries: jest.fn(() => ({ - countries: null, + countries: [], isLoading: false, error: null, - fetchCountries: jest.fn(), })), })); -jest.mock('./useRampsPreferredProviderAutoSet', () => ({ - useRampsPreferredProviderAutoSet: jest.fn(), +jest.mock('./useRampsPaymentMethods', () => ({ + useRampsPaymentMethods: jest.fn(() => ({ + paymentMethods: [], + selectedPaymentMethod: null, + setSelectedPaymentMethod: jest.fn(), + isLoading: false, + error: null, + })), })); const createMockStore = () => @@ -109,24 +110,28 @@ describe('useRampsController', () => { userRegion: null, userRegionLoading: false, userRegionError: null, - preferredProvider: null, + selectedProvider: null, providers: [], providersLoading: false, providersError: null, tokens: null, + selectedToken: null, tokensLoading: false, tokensError: null, - countries: null, + countries: [], countriesLoading: false, countriesError: null, + paymentMethods: [], + selectedPaymentMethod: null, + paymentMethodsLoading: false, + paymentMethodsError: null, }); expect(typeof result.current.fetchUserRegion).toBe('function'); expect(typeof result.current.setUserRegion).toBe('function'); - expect(typeof result.current.setPreferredProvider).toBe('function'); - expect(typeof result.current.fetchProviders).toBe('function'); - expect(typeof result.current.fetchTokens).toBe('function'); - expect(typeof result.current.fetchCountries).toBe('function'); + expect(typeof result.current.setSelectedProvider).toBe('function'); + expect(typeof result.current.setSelectedToken).toBe('function'); + expect(typeof result.current.setSelectedPaymentMethod).toBe('function'); }); it('passes options to child hooks', () => { @@ -151,7 +156,8 @@ describe('useRampsController', () => { crypto: 'ETH', }); expect(useRampsTokens).toHaveBeenCalledWith('us-ny', 'sell'); - expect(useRampsCountries).toHaveBeenCalledWith('sell'); + expect(useRampsCountries).toHaveBeenCalled(); + expect(useRampsPaymentMethods).toHaveBeenCalled(); }); it('passes undefined options when not provided', () => { @@ -162,6 +168,7 @@ describe('useRampsController', () => { expect(useRampsProviders).toHaveBeenCalledWith(undefined, undefined); expect(useRampsTokens).toHaveBeenCalledWith(undefined, undefined); - expect(useRampsCountries).toHaveBeenCalledWith(undefined); + expect(useRampsCountries).toHaveBeenCalled(); + expect(useRampsPaymentMethods).toHaveBeenCalled(); }); }); diff --git a/app/components/UI/Ramp/hooks/useRampsController.ts b/app/components/UI/Ramp/hooks/useRampsController.ts index 215420388aa..0c8985b6505 100644 --- a/app/components/UI/Ramp/hooks/useRampsController.ts +++ b/app/components/UI/Ramp/hooks/useRampsController.ts @@ -2,10 +2,6 @@ import { useRampsUserRegion, type UseRampsUserRegionResult, } from './useRampsUserRegion'; -import { - useRampsPreferredProvider, - type UseRampsPreferredProviderResult, -} from './useRampsPreferredProvider'; import { useRampsProviders, type UseRampsProvidersResult, @@ -15,7 +11,10 @@ import { useRampsCountries, type UseRampsCountriesResult, } from './useRampsCountries'; -import { useRampsPreferredProviderAutoSet } from './useRampsPreferredProviderAutoSet'; +import { + useRampsPaymentMethods, + type UseRampsPaymentMethodsResult, +} from './useRampsPaymentMethods'; /** * Options for the useRampsController hook. @@ -54,27 +53,33 @@ export interface UseRampsControllerResult { fetchUserRegion: UseRampsUserRegionResult['fetchUserRegion']; setUserRegion: UseRampsUserRegionResult['setUserRegion']; - // Preferred provider - preferredProvider: UseRampsPreferredProviderResult['preferredProvider']; - setPreferredProvider: UseRampsPreferredProviderResult['setPreferredProvider']; + // Selected provider + selectedProvider: UseRampsProvidersResult['selectedProvider']; + setSelectedProvider: UseRampsProvidersResult['setSelectedProvider']; // Providers providers: UseRampsProvidersResult['providers']; providersLoading: UseRampsProvidersResult['isLoading']; providersError: UseRampsProvidersResult['error']; - fetchProviders: UseRampsProvidersResult['fetchProviders']; // Tokens tokens: UseRampsTokensResult['tokens']; + selectedToken: UseRampsTokensResult['selectedToken']; + setSelectedToken: UseRampsTokensResult['setSelectedToken']; tokensLoading: UseRampsTokensResult['isLoading']; tokensError: UseRampsTokensResult['error']; - fetchTokens: UseRampsTokensResult['fetchTokens']; // Countries countries: UseRampsCountriesResult['countries']; countriesLoading: UseRampsCountriesResult['isLoading']; countriesError: UseRampsCountriesResult['error']; - fetchCountries: UseRampsCountriesResult['fetchCountries']; + + // Payment methods + paymentMethods: UseRampsPaymentMethodsResult['paymentMethods']; + selectedPaymentMethod: UseRampsPaymentMethodsResult['selectedPaymentMethod']; + setSelectedPaymentMethod: UseRampsPaymentMethodsResult['setSelectedPaymentMethod']; + paymentMethodsLoading: UseRampsPaymentMethodsResult['isLoading']; + paymentMethodsError: UseRampsPaymentMethodsResult['error']; } /** @@ -94,27 +99,31 @@ export interface UseRampsControllerResult { * fetchUserRegion, * setUserRegion, * - * // Preferred provider - * preferredProvider, - * setPreferredProvider, - * * // Providers + * selectedProvider, + * setSelectedProvider, * providers, * providersLoading, * providersError, - * fetchProviders, * * // Tokens * tokens, + * selectedToken, + * setSelectedToken, * tokensLoading, * tokensError, - * fetchTokens, * * // Countries * countries, * countriesLoading, * countriesError, - * fetchCountries, + * + * // Payment methods + * paymentMethods, + * selectedPaymentMethod, + * setSelectedPaymentMethod, + * paymentMethodsLoading, + * paymentMethodsError, * * } = useRampsController({ action: 'buy' }); * ``` @@ -130,31 +139,35 @@ export function useRampsController( setUserRegion, } = useRampsUserRegion(); - const { preferredProvider, setPreferredProvider } = - useRampsPreferredProvider(); - const { providers, + selectedProvider, + setSelectedProvider, isLoading: providersLoading, error: providersError, - fetchProviders, } = useRampsProviders(options?.region, options?.providerFilters); const { tokens, + selectedToken, + setSelectedToken, isLoading: tokensLoading, error: tokensError, - fetchTokens, } = useRampsTokens(options?.region, options?.action); const { countries, isLoading: countriesLoading, error: countriesError, - fetchCountries, - } = useRampsCountries(options?.action); + } = useRampsCountries(); - useRampsPreferredProviderAutoSet(); + const { + paymentMethods, + selectedPaymentMethod, + setSelectedPaymentMethod, + isLoading: paymentMethodsLoading, + error: paymentMethodsError, + } = useRampsPaymentMethods(); return { // User region @@ -164,27 +177,32 @@ export function useRampsController( fetchUserRegion, setUserRegion, - // Preferred provider - preferredProvider, - setPreferredProvider, + // Selected provider + selectedProvider, + setSelectedProvider, - // Providers providers, providersLoading, providersError, - fetchProviders, // Tokens tokens, + selectedToken, + setSelectedToken, tokensLoading, tokensError, - fetchTokens, // Countries countries, countriesLoading, countriesError, - fetchCountries, + + // Payment methods + paymentMethods, + selectedPaymentMethod, + setSelectedPaymentMethod, + paymentMethodsLoading, + paymentMethodsError, }; } diff --git a/app/components/UI/Ramp/hooks/useRampsCountries.test.ts b/app/components/UI/Ramp/hooks/useRampsCountries.test.ts index 5c9ffb016f0..d1f25fe30e3 100644 --- a/app/components/UI/Ramp/hooks/useRampsCountries.test.ts +++ b/app/components/UI/Ramp/hooks/useRampsCountries.test.ts @@ -4,7 +4,6 @@ import { configureStore } from '@reduxjs/toolkit'; import React from 'react'; import { useRampsCountries } from './useRampsCountries'; import { RequestStatus, type Country } from '@metamask/ramps-controller'; -import Engine from '../../../../core/Engine'; const mockCountries: Country[] = [ { @@ -17,7 +16,7 @@ const mockCountries: Country[] = [ template: 'XXX-XXX-XXXX', }, currency: 'USD', - supported: true, + supported: { buy: true, sell: true }, }, { isoCode: 'CA', @@ -29,18 +28,10 @@ const mockCountries: Country[] = [ template: 'XXX-XXX-XXXX', }, currency: 'CAD', - supported: true, + supported: { buy: true, sell: true }, }, ]; -jest.mock('../../../../core/Engine', () => ({ - context: { - RampsController: { - getCountries: jest.fn().mockResolvedValue(mockCountries), - }, - }, -})); - const createMockStore = (rampsControllerState = {}) => configureStore({ reducer: { @@ -48,9 +39,10 @@ const createMockStore = (rampsControllerState = {}) => backgroundState: { RampsController: { userRegion: null, - preferredProvider: null, + selectedProvider: null, providers: [], tokens: null, + countries: [], requests: {}, ...rampsControllerState, }, @@ -67,97 +59,37 @@ const wrapper = (store: ReturnType) => describe('useRampsCountries', () => { beforeEach(() => { jest.clearAllMocks(); - ( - Engine.context.RampsController.getCountries as jest.Mock - ).mockResolvedValue(mockCountries); }); describe('return value structure', () => { - it('returns isLoading, error, countries, and fetchCountries', () => { + it('returns countries, isLoading, and error', () => { const store = createMockStore(); const { result } = renderHook(() => useRampsCountries(), { wrapper: wrapper(store), }); expect(result.current).toMatchObject({ + countries: [], isLoading: false, error: null, - countries: null, }); - expect(typeof result.current.fetchCountries).toBe('function'); - }); - }); - - describe('action parameter', () => { - it('defaults to buy when not provided', () => { - const store = createMockStore(); - const { result } = renderHook(() => useRampsCountries(), { - wrapper: wrapper(store), - }); - expect(result.current).toBeDefined(); - }); - - it('uses buy action when provided', () => { - const store = createMockStore({ - requests: { - 'getCountries:["buy"]': { - status: RequestStatus.SUCCESS, - data: mockCountries, - error: null, - timestamp: Date.now(), - lastFetchedAt: Date.now(), - }, - }, - }); - const { result } = renderHook(() => useRampsCountries('buy'), { - wrapper: wrapper(store), - }); - expect(result.current.countries).toEqual(mockCountries); - }); - - it('uses sell action when provided', () => { - const store = createMockStore({ - requests: { - 'getCountries:["sell"]': { - status: RequestStatus.SUCCESS, - data: mockCountries, - error: null, - timestamp: Date.now(), - lastFetchedAt: Date.now(), - }, - }, - }); - const { result } = renderHook(() => useRampsCountries('sell'), { - wrapper: wrapper(store), - }); - expect(result.current.countries).toEqual(mockCountries); }); }); describe('countries state', () => { - it('returns countries from request data', () => { - const store = createMockStore({ - requests: { - 'getCountries:["buy"]': { - status: RequestStatus.SUCCESS, - data: mockCountries, - error: null, - timestamp: Date.now(), - lastFetchedAt: Date.now(), - }, - }, - }); - const { result } = renderHook(() => useRampsCountries('buy'), { + it('returns countries from state', () => { + const store = createMockStore({ countries: mockCountries }); + const { result } = renderHook(() => useRampsCountries(), { wrapper: wrapper(store), }); expect(result.current.countries).toEqual(mockCountries); }); - it('returns null when data is not available', () => { + it('returns empty array when countries are not available', () => { const store = createMockStore(); const { result } = renderHook(() => useRampsCountries(), { wrapper: wrapper(store), }); - expect(result.current.countries).toBeNull(); + expect(result.current.countries).toEqual([]); }); }); @@ -165,7 +97,7 @@ describe('useRampsCountries', () => { it('returns isLoading true when request is loading', () => { const store = createMockStore({ requests: { - 'getCountries:["buy"]': { + 'getCountries:[]': { status: RequestStatus.LOADING, data: null, error: null, @@ -174,7 +106,7 @@ describe('useRampsCountries', () => { }, }, }); - const { result } = renderHook(() => useRampsCountries('buy'), { + const { result } = renderHook(() => useRampsCountries(), { wrapper: wrapper(store), }); expect(result.current.isLoading).toBe(true); @@ -193,7 +125,7 @@ describe('useRampsCountries', () => { it('returns error from request state', () => { const store = createMockStore({ requests: { - 'getCountries:["buy"]': { + 'getCountries:[]': { status: RequestStatus.ERROR, data: null, error: 'Network error', @@ -202,73 +134,10 @@ describe('useRampsCountries', () => { }, }, }); - const { result } = renderHook(() => useRampsCountries('buy'), { - wrapper: wrapper(store), - }); - expect(result.current.error).toBe('Network error'); - }); - }); - - describe('fetchCountries', () => { - it('calls getCountries with hook action when called without arguments', async () => { - const store = createMockStore(); - const { result } = renderHook(() => useRampsCountries('sell'), { - wrapper: wrapper(store), - }); - await result.current.fetchCountries(); - expect(Engine.context.RampsController.getCountries).toHaveBeenCalledWith( - 'sell', - undefined, - ); - }); - - it('calls getCountries with provided action', async () => { - const store = createMockStore(); - const { result } = renderHook(() => useRampsCountries('buy'), { - wrapper: wrapper(store), - }); - await result.current.fetchCountries('sell'); - expect(Engine.context.RampsController.getCountries).toHaveBeenCalledWith( - 'sell', - undefined, - ); - }); - - it('calls getCountries with options when provided', async () => { - const store = createMockStore(); - const { result } = renderHook(() => useRampsCountries(), { - wrapper: wrapper(store), - }); - await result.current.fetchCountries('buy', { forceRefresh: true }); - expect(Engine.context.RampsController.getCountries).toHaveBeenCalledWith( - 'buy', - { forceRefresh: true }, - ); - }); - - it('returns countries data', async () => { - const store = createMockStore(); const { result } = renderHook(() => useRampsCountries(), { wrapper: wrapper(store), }); - const countries = await result.current.fetchCountries(); - expect(countries).toEqual(mockCountries); - }); - - it('rejects with error when getCountries fails', async () => { - const store = createMockStore(); - const mockGetCountries = Engine.context.RampsController - .getCountries as jest.Mock; - mockGetCountries.mockReset(); - mockGetCountries.mockRejectedValue(new Error('Network error')); - - const { result } = renderHook(() => useRampsCountries(), { - wrapper: wrapper(store), - }); - - await expect(result.current.fetchCountries()).rejects.toThrow( - 'Network error', - ); + expect(result.current.error).toBe('Network error'); }); }); }); diff --git a/app/components/UI/Ramp/hooks/useRampsCountries.ts b/app/components/UI/Ramp/hooks/useRampsCountries.ts index 6ecf52115da..f9d79728c85 100644 --- a/app/components/UI/Ramp/hooks/useRampsCountries.ts +++ b/app/components/UI/Ramp/hooks/useRampsCountries.ts @@ -1,9 +1,9 @@ -import { useCallback, useMemo } from 'react'; import { useSelector } from 'react-redux'; -import Engine from '../../../../core/Engine'; -import { selectCountriesRequest } from '../../../../selectors/rampsController'; import { - ExecuteRequestOptions, + selectCountries, + selectCountriesRequest, +} from '../../../../selectors/rampsController'; +import { RequestSelectorResult, type Country, } from '@metamask/ramps-controller'; @@ -12,6 +12,10 @@ import { * Result returned by the useRampsCountries hook. */ export interface UseRampsCountriesResult { + /** + * The list of countries available for ramp actions. + */ + countries: Country[]; /** * Whether the countries request is currently loading. */ @@ -20,52 +24,25 @@ export interface UseRampsCountriesResult { * The error message if the request failed, or null. */ error: string | null; - /** - * The cached countries data if available, or null. - */ - countries: Country[] | null; - /** - * Fetch countries for a given action. - */ - fetchCountries: ( - action?: 'buy' | 'sell', - options?: ExecuteRequestOptions, - ) => Promise; } /** - * Hook to get countries request state from RampsController. + * Hook to get countries state from RampsController. * This hook assumes Engine is already initialized. * - * @param action - Optional action type ('buy' or 'sell'). Defaults to 'buy'. - * @returns Countries request state and fetch function. + * @returns Countries state. */ -export function useRampsCountries( - action: 'buy' | 'sell' = 'buy', -): UseRampsCountriesResult { - const requestSelector = useMemo( - () => selectCountriesRequest(action), - [action], - ); +export function useRampsCountries(): UseRampsCountriesResult { + const countries = useSelector(selectCountries); - const { isFetching, error, data } = useSelector( - requestSelector, + const { isFetching, error } = useSelector( + selectCountriesRequest, ) as RequestSelectorResult; - const fetchCountries = useCallback( - async ( - fetchAction: 'buy' | 'sell' = action, - options?: ExecuteRequestOptions, - ) => - await Engine.context.RampsController.getCountries(fetchAction, options), - [action], - ); - return { - countries: data ?? null, + countries, isLoading: isFetching, error, - fetchCountries, }; } diff --git a/app/components/UI/Ramp/hooks/useRampsPaymentMethods.test.ts b/app/components/UI/Ramp/hooks/useRampsPaymentMethods.test.ts new file mode 100644 index 00000000000..52ece050980 --- /dev/null +++ b/app/components/UI/Ramp/hooks/useRampsPaymentMethods.test.ts @@ -0,0 +1,244 @@ +import { renderHook, act } from '@testing-library/react-native'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import React from 'react'; +import { useRampsPaymentMethods } from './useRampsPaymentMethods'; +import { + RequestStatus, + type UserRegion, + type PaymentMethod, +} from '@metamask/ramps-controller'; +import Engine from '../../../../core/Engine'; + +jest.mock('../../../../core/Engine', () => ({ + context: { + RampsController: { + setSelectedPaymentMethod: jest.fn(), + }, + }, +})); + +const mockUserRegion: UserRegion = { + country: { + isoCode: 'US', + name: 'United States', + flag: '🇺🇸', + phone: { + prefix: '+1', + placeholder: '(XXX) XXX-XXXX', + template: 'XXX-XXX-XXXX', + }, + currency: 'USD', + supported: { buy: true, sell: true }, + }, + state: { stateId: 'CA', name: 'California' }, + regionCode: 'us-ca', +}; + +const mockSelectedToken = { + assetId: 'eip155:1/erc20:0x0000000000000000000000000000000000000000', + chainId: 'eip155:1', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + iconUrl: 'https://example.com/eth-icon.png', + tokenSupported: true, +}; + +const mockSelectedProvider = { + id: 'provider-1', + name: 'Provider 1', + environmentType: 'PRODUCTION', + description: 'Provider 1 Description', + hqAddress: '123 Provider 1 St, City, ST 12345', + links: [], + logos: { + light: 'https://example.com/logo1-light.png', + dark: 'https://example.com/logo1-dark.png', + height: 24, + width: 79, + }, +}; + +const mockPaymentMethods: PaymentMethod[] = [ + { + id: '/payments/debit-credit-card', + paymentType: 'debit-credit-card', + name: 'Debit/Credit Card', + score: 100, + icon: 'card', + }, + { + id: '/payments/bank-transfer', + paymentType: 'bank-transfer', + name: 'Bank Transfer', + score: 90, + icon: 'bank', + }, +]; + +const createMockStore = (rampsControllerState = {}) => + configureStore({ + reducer: { + engine: () => ({ + backgroundState: { + RampsController: { + userRegion: null, + selectedToken: null, + selectedProvider: null, + paymentMethods: [], + selectedPaymentMethod: null, + requests: {}, + ...rampsControllerState, + }, + }, + }), + }, + }); + +const wrapper = (store: ReturnType) => + function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(Provider, { store } as never, children); + }; + +describe('useRampsPaymentMethods', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('return value structure', () => { + it('returns paymentMethods, selectedPaymentMethod, setSelectedPaymentMethod, isLoading, and error', () => { + const store = createMockStore(); + const { result } = renderHook(() => useRampsPaymentMethods(), { + wrapper: wrapper(store), + }); + expect(result.current).toMatchObject({ + paymentMethods: [], + selectedPaymentMethod: null, + isLoading: false, + error: null, + }); + expect(typeof result.current.setSelectedPaymentMethod).toBe('function'); + }); + }); + + describe('paymentMethods state', () => { + it('returns paymentMethods from state', () => { + const store = createMockStore({ paymentMethods: mockPaymentMethods }); + const { result } = renderHook(() => useRampsPaymentMethods(), { + wrapper: wrapper(store), + }); + expect(result.current.paymentMethods).toEqual(mockPaymentMethods); + }); + + it('returns empty array when paymentMethods are not available', () => { + const store = createMockStore(); + const { result } = renderHook(() => useRampsPaymentMethods(), { + wrapper: wrapper(store), + }); + expect(result.current.paymentMethods).toEqual([]); + }); + }); + + describe('selectedPaymentMethod state', () => { + it('returns selectedPaymentMethod from state', () => { + const store = createMockStore({ + selectedPaymentMethod: mockPaymentMethods[0], + }); + const { result } = renderHook(() => useRampsPaymentMethods(), { + wrapper: wrapper(store), + }); + expect(result.current.selectedPaymentMethod).toEqual( + mockPaymentMethods[0], + ); + }); + + it('returns null when selectedPaymentMethod is not available', () => { + const store = createMockStore(); + const { result } = renderHook(() => useRampsPaymentMethods(), { + wrapper: wrapper(store), + }); + expect(result.current.selectedPaymentMethod).toBeNull(); + }); + }); + + describe('loading state', () => { + it('returns isLoading true when request is loading', () => { + const store = createMockStore({ + userRegion: mockUserRegion, + selectedToken: mockSelectedToken, + selectedProvider: mockSelectedProvider, + requests: { + [`getPaymentMethods:["us-ca","usd","${mockSelectedToken.assetId}","${mockSelectedProvider.id}"]`]: + { + status: RequestStatus.LOADING, + data: null, + error: null, + timestamp: Date.now(), + lastFetchedAt: Date.now(), + }, + }, + }); + const { result } = renderHook(() => useRampsPaymentMethods(), { + wrapper: wrapper(store), + }); + expect(result.current.isLoading).toBe(true); + }); + }); + + describe('error state', () => { + it('returns error from request state', () => { + const store = createMockStore({ + userRegion: mockUserRegion, + selectedToken: mockSelectedToken, + selectedProvider: mockSelectedProvider, + requests: { + [`getPaymentMethods:["us-ca","usd","${mockSelectedToken.assetId}","${mockSelectedProvider.id}"]`]: + { + status: RequestStatus.ERROR, + data: null, + error: 'Network error', + timestamp: Date.now(), + lastFetchedAt: Date.now(), + }, + }, + }); + const { result } = renderHook(() => useRampsPaymentMethods(), { + wrapper: wrapper(store), + }); + expect(result.current.error).toBe('Network error'); + }); + }); + + describe('setSelectedPaymentMethod', () => { + it('calls Engine.context.RampsController.setSelectedPaymentMethod with payment method id', () => { + const store = createMockStore(); + const { result } = renderHook(() => useRampsPaymentMethods(), { + wrapper: wrapper(store), + }); + + act(() => { + result.current.setSelectedPaymentMethod(mockPaymentMethods[0]); + }); + + expect( + Engine.context.RampsController.setSelectedPaymentMethod, + ).toHaveBeenCalledWith(mockPaymentMethods[0].id); + }); + + it('calls Engine.context.RampsController.setSelectedPaymentMethod with undefined when payment method is null', () => { + const store = createMockStore(); + const { result } = renderHook(() => useRampsPaymentMethods(), { + wrapper: wrapper(store), + }); + + act(() => { + result.current.setSelectedPaymentMethod(null); + }); + + expect( + Engine.context.RampsController.setSelectedPaymentMethod, + ).toHaveBeenCalledWith(undefined); + }); + }); +}); diff --git a/app/components/UI/Ramp/hooks/useRampsPaymentMethods.ts b/app/components/UI/Ramp/hooks/useRampsPaymentMethods.ts new file mode 100644 index 00000000000..2084fa4047a --- /dev/null +++ b/app/components/UI/Ramp/hooks/useRampsPaymentMethods.ts @@ -0,0 +1,105 @@ +import { useCallback, useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { + selectPaymentMethods, + selectPaymentMethodsRequest, + selectSelectedPaymentMethod, + selectSelectedProvider, + selectSelectedToken, + selectUserRegion, +} from '../../../../selectors/rampsController'; +import { + RequestSelectorResult, + type PaymentMethod, + type PaymentMethodsResponse, +} from '@metamask/ramps-controller'; +import Engine from '../../../../core/Engine'; + +/** + * Result returned by the useRampsPaymentMethods hook. + */ +export interface UseRampsPaymentMethodsResult { + /** + * The list of payment methods available for the current context. + */ + paymentMethods: PaymentMethod[]; + /** + * The currently selected payment method, or null if none selected. + */ + selectedPaymentMethod: PaymentMethod | null; + /** + * Sets the selected payment method by ID. + * @param paymentMethod - The payment method to select, or null to clear selection. + */ + setSelectedPaymentMethod: (paymentMethod: PaymentMethod | null) => void; + /** + * Whether the payment methods request is currently loading. + */ + isLoading: boolean; + /** + * The error message if the request failed, or null. + */ + error: string | null; +} + +/** + * Hook to get payment methods state from RampsController. + * This hook assumes Engine is already initialized. + * + * @returns Payment methods state. + */ +export function useRampsPaymentMethods(): UseRampsPaymentMethodsResult { + const paymentMethods = useSelector(selectPaymentMethods); + const selectedPaymentMethod = useSelector(selectSelectedPaymentMethod); + + const userRegion = useSelector(selectUserRegion); + const selectedToken = useSelector(selectSelectedToken); + const selectedProvider = useSelector(selectSelectedProvider); + + const regionCode = useMemo( + () => userRegion?.regionCode ?? '', + [userRegion?.regionCode], + ); + + const fiat = useMemo( + () => userRegion?.country?.currency ?? '', + [userRegion?.country?.currency], + ); + + const assetId = useMemo( + () => selectedToken?.assetId ?? '', + [selectedToken?.assetId], + ); + + const providerId = useMemo( + () => selectedProvider?.id ?? '', + [selectedProvider?.id], + ); + + const requestSelector = useMemo( + () => selectPaymentMethodsRequest(regionCode, fiat, assetId, providerId), + [regionCode, fiat, assetId, providerId], + ); + + const { isFetching, error } = useSelector( + requestSelector, + ) as RequestSelectorResult; + + const setSelectedPaymentMethod = useCallback( + (paymentMethod: PaymentMethod | null) => + Engine.context.RampsController.setSelectedPaymentMethod( + paymentMethod?.id, + ), + [], + ); + + return { + paymentMethods, + selectedPaymentMethod, + setSelectedPaymentMethod, + isLoading: isFetching, + error, + }; +} + +export default useRampsPaymentMethods; diff --git a/app/components/UI/Ramp/hooks/useRampsPreferredProvider.test.ts b/app/components/UI/Ramp/hooks/useRampsPreferredProvider.test.ts deleted file mode 100644 index a2fff3049c1..00000000000 --- a/app/components/UI/Ramp/hooks/useRampsPreferredProvider.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { renderHook } from '@testing-library/react-native'; -import { Provider } from 'react-redux'; -import { configureStore } from '@reduxjs/toolkit'; -import React from 'react'; -import { useRampsPreferredProvider } from './useRampsPreferredProvider'; -import type { Provider as RampProvider } from '@metamask/ramps-controller'; -import Engine from '../../../../core/Engine'; - -const mockProvider: RampProvider = { - id: 'test-provider', - name: 'Test Provider', - environmentType: 'PRODUCTION', - description: 'Test Provider Description', - hqAddress: '123 Test St, Test City, TC 12345', - links: [], - logos: { - light: 'https://example.com/logo-light.png', - dark: 'https://example.com/logo-dark.png', - height: 24, - width: 79, - }, -}; - -jest.mock('../../../../core/Engine', () => ({ - context: { - RampsController: { - setPreferredProvider: jest.fn(), - }, - }, -})); - -const createMockStore = (rampsControllerState = {}) => - configureStore({ - reducer: { - engine: () => ({ - backgroundState: { - RampsController: { - preferredProvider: null, - ...rampsControllerState, - }, - }, - }), - }, - }); - -const wrapper = (store: ReturnType) => - function Wrapper({ children }: { children: React.ReactNode }) { - return React.createElement(Provider, { store } as never, children); - }; - -describe('useRampsPreferredProvider', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('return value structure', () => { - it('returns preferredProvider and setPreferredProvider', () => { - const store = createMockStore(); - const { result } = renderHook(() => useRampsPreferredProvider(), { - wrapper: wrapper(store), - }); - expect(result.current).toMatchObject({ - preferredProvider: null, - }); - expect(typeof result.current.setPreferredProvider).toBe('function'); - }); - }); - - describe('preferredProvider state', () => { - it('returns preferredProvider from state', () => { - const store = createMockStore({ preferredProvider: mockProvider }); - const { result } = renderHook(() => useRampsPreferredProvider(), { - wrapper: wrapper(store), - }); - expect(result.current.preferredProvider).toEqual(mockProvider); - }); - - it('returns null when preferredProvider is not set', () => { - const store = createMockStore({ preferredProvider: null }); - const { result } = renderHook(() => useRampsPreferredProvider(), { - wrapper: wrapper(store), - }); - expect(result.current.preferredProvider).toBeNull(); - }); - }); - - describe('setPreferredProvider', () => { - it('calls setPreferredProvider on controller with provider', () => { - const store = createMockStore(); - const { result } = renderHook(() => useRampsPreferredProvider(), { - wrapper: wrapper(store), - }); - result.current.setPreferredProvider(mockProvider); - expect( - Engine.context.RampsController.setPreferredProvider, - ).toHaveBeenCalledWith(mockProvider); - }); - - it('calls setPreferredProvider on controller with null', () => { - const store = createMockStore(); - const { result } = renderHook(() => useRampsPreferredProvider(), { - wrapper: wrapper(store), - }); - result.current.setPreferredProvider(null); - expect( - Engine.context.RampsController.setPreferredProvider, - ).toHaveBeenCalledWith(null); - }); - }); -}); diff --git a/app/components/UI/Ramp/hooks/useRampsPreferredProvider.ts b/app/components/UI/Ramp/hooks/useRampsPreferredProvider.ts deleted file mode 100644 index 8c081bdce6a..00000000000 --- a/app/components/UI/Ramp/hooks/useRampsPreferredProvider.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useCallback } from 'react'; -import { useSelector } from 'react-redux'; -import Engine from '../../../../core/Engine'; -import { selectPreferredProvider } from '../../../../selectors/rampsController'; -import type { Provider } from '@metamask/ramps-controller'; - -/** - * Result returned by the useRampsPreferredProvider hook. - */ -export interface UseRampsPreferredProviderResult { - /** - * The user's preferred provider, or null if not set. - */ - preferredProvider: Provider | null; - /** - * Set the user's preferred provider. - */ - setPreferredProvider: (provider: Provider | null) => void; -} - -/** - * Hook to get and set the user's preferred provider from RampsController. - * This hook assumes Engine is already initialized. - * - * @returns Preferred provider state and setter function. - */ -export function useRampsPreferredProvider(): UseRampsPreferredProviderResult { - const preferredProvider = useSelector(selectPreferredProvider); - - const setPreferredProvider = useCallback((provider: Provider | null) => { - Engine.context.RampsController.setPreferredProvider(provider); - }, []); - - return { - preferredProvider, - setPreferredProvider, - }; -} - -export default useRampsPreferredProvider; diff --git a/app/components/UI/Ramp/hooks/useRampsPreferredProviderAutoSet.test.ts b/app/components/UI/Ramp/hooks/useRampsPreferredProviderAutoSet.test.ts deleted file mode 100644 index c09aea3eb2f..00000000000 --- a/app/components/UI/Ramp/hooks/useRampsPreferredProviderAutoSet.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { renderHookWithProvider } from '../../../../util/test/renderWithProvider'; -import { waitFor } from '@testing-library/react-native'; -import { useRampsPreferredProviderAutoSet } from './useRampsPreferredProviderAutoSet'; -import { determinePreferredProvider } from '../utils/determinePreferredProvider'; -import { useRampsPreferredProvider } from './useRampsPreferredProvider'; -import type { Provider as RampProvider } from '@metamask/ramps-controller'; -import type { FiatOrder } from '../../../../reducers/fiatOrders/types'; -import { - FIAT_ORDER_PROVIDERS, - FIAT_ORDER_STATES, -} from '../../../../constants/on-ramp'; -import { Order } from '@consensys/on-ramp-sdk'; -import initialRootState from '../../../../util/test/initial-root-state'; - -jest.mock('../utils/determinePreferredProvider'); -jest.mock('./useRampsPreferredProvider'); - -jest.mock( - '../../../../selectors/multichainAccounts/accountTreeController', - () => ({ - ...jest.requireActual( - '../../../../selectors/multichainAccounts/accountTreeController', - ), - selectSelectedAccountGroupWithInternalAccountsAddresses: () => ['0x123'], - }), -); - -const mockProvider: RampProvider = { - id: 'test-provider', - name: 'Test Provider', - environmentType: 'PRODUCTION', - description: 'Test Provider Description', - hqAddress: '123 Test St', - links: [], - logos: { - light: 'https://example.com/logo-light.png', - dark: 'https://example.com/logo-dark.png', - height: 24, - width: 79, - }, -}; - -const mockSetPreferredProvider = jest.fn(); -const mockAccount = '0x123'; - -beforeEach(() => { - jest.clearAllMocks(); - (useRampsPreferredProvider as jest.Mock).mockReturnValue({ - preferredProvider: null, - setPreferredProvider: mockSetPreferredProvider, - }); -}); - -const createMockState = ( - orders: FiatOrder[] = [], - providers: RampProvider[] = [], -) => ({ - ...initialRootState, - fiatOrders: { - ...initialRootState.fiatOrders, - orders, - }, - engine: { - ...initialRootState.engine, - backgroundState: { - ...initialRootState.engine.backgroundState, - RampsController: { - ...initialRootState.engine.backgroundState.RampsController, - providers, - }, - }, - }, -}); - -describe('useRampsPreferredProviderAutoSet', () => { - it('does not set provider when providers list is empty', () => { - renderHookWithProvider(() => useRampsPreferredProviderAutoSet(), { - state: createMockState([], []), - }); - - expect(determinePreferredProvider).not.toHaveBeenCalled(); - expect(mockSetPreferredProvider).not.toHaveBeenCalled(); - }); - - it('sets preferred provider when providers are available', async () => { - const orders: FiatOrder[] = [ - { - id: 'order-1', - provider: FIAT_ORDER_PROVIDERS.AGGREGATOR, - createdAt: 1000, - amount: '100', - currency: 'USD', - cryptocurrency: 'ETH', - state: FIAT_ORDER_STATES.COMPLETED, - account: mockAccount, - network: '1', - orderType: 'BUY', - excludeFromPurchases: false, - data: { - provider: { id: 'test-provider' }, - } as Order, - }, - ]; - - (determinePreferredProvider as jest.Mock).mockReturnValue(mockProvider); - - renderHookWithProvider(() => useRampsPreferredProviderAutoSet(), { - state: createMockState(orders, [mockProvider]), - }); - - await waitFor(() => { - expect(determinePreferredProvider).toHaveBeenCalledWith(orders, [ - mockProvider, - ]); - expect(mockSetPreferredProvider).toHaveBeenCalledWith(mockProvider); - }); - }); - - it('does not set provider if determined provider is null', async () => { - (determinePreferredProvider as jest.Mock).mockReturnValue(null); - - renderHookWithProvider(() => useRampsPreferredProviderAutoSet(), { - state: createMockState([], [mockProvider]), - }); - - await waitFor(() => { - expect(determinePreferredProvider).toHaveBeenCalled(); - }); - - expect(mockSetPreferredProvider).not.toHaveBeenCalled(); - }); - - it('does not update if preferred provider is already set to the determined provider', async () => { - const orders: FiatOrder[] = []; - - (useRampsPreferredProvider as jest.Mock).mockReturnValue({ - preferredProvider: mockProvider, - setPreferredProvider: mockSetPreferredProvider, - }); - - (determinePreferredProvider as jest.Mock).mockReturnValue(mockProvider); - - renderHookWithProvider(() => useRampsPreferredProviderAutoSet(), { - state: createMockState(orders, [mockProvider]), - }); - - await waitFor(() => { - expect(determinePreferredProvider).toHaveBeenCalled(); - }); - - expect(mockSetPreferredProvider).not.toHaveBeenCalled(); - }); -}); diff --git a/app/components/UI/Ramp/hooks/useRampsPreferredProviderAutoSet.ts b/app/components/UI/Ramp/hooks/useRampsPreferredProviderAutoSet.ts deleted file mode 100644 index ca338d0e7c3..00000000000 --- a/app/components/UI/Ramp/hooks/useRampsPreferredProviderAutoSet.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { useEffect } from 'react'; -import { useSelector } from 'react-redux'; -import { getOrders } from '../../../../reducers/fiatOrders'; -import { selectProviders } from '../../../../selectors/rampsController'; -import { determinePreferredProvider } from '../utils/determinePreferredProvider'; -import { useRampsPreferredProvider } from './useRampsPreferredProvider'; - -/** - * Hook that automatically sets the preferred provider based on user's order history - * and available providers. This hook watches for changes in orders and providers, - * and updates the preferred provider accordingly. - * - * The preferred provider is determined by: - * 1. Most recent completed order's provider (if available in providers list) - * 2. Transak (if no orders or provider from order not found) - * 3. First provider in list (if Transak not available) - * - * This hook should be used in components that need to automatically manage - * the preferred provider state. - */ -export function useRampsPreferredProviderAutoSet(): void { - const orders = useSelector(getOrders); - const providers = useSelector(selectProviders); - const { setPreferredProvider, preferredProvider } = - useRampsPreferredProvider(); - - useEffect(() => { - if (providers.length === 0) { - return; - } - - const determinedProvider = determinePreferredProvider(orders, providers); - - if (determinedProvider) { - const currentProviderId = preferredProvider?.id?.toLowerCase(); - const determinedProviderId = determinedProvider.id?.toLowerCase(); - - if (currentProviderId !== determinedProviderId) { - setPreferredProvider(determinedProvider); - } - } - }, [orders, providers, setPreferredProvider, preferredProvider]); -} - -export default useRampsPreferredProviderAutoSet; diff --git a/app/components/UI/Ramp/hooks/useRampsProviders.test.ts b/app/components/UI/Ramp/hooks/useRampsProviders.test.ts index 60f367aa957..294c9c91987 100644 --- a/app/components/UI/Ramp/hooks/useRampsProviders.test.ts +++ b/app/components/UI/Ramp/hooks/useRampsProviders.test.ts @@ -1,4 +1,4 @@ -import { renderHook } from '@testing-library/react-native'; +import { renderHook, act } from '@testing-library/react-native'; import { Provider } from 'react-redux'; import { configureStore } from '@reduxjs/toolkit'; import React from 'react'; @@ -10,6 +10,14 @@ import { } from '@metamask/ramps-controller'; import Engine from '../../../../core/Engine'; +jest.mock('../../../../core/Engine', () => ({ + context: { + RampsController: { + setSelectedProvider: jest.fn(), + }, + }, +})); + const mockUserRegion: UserRegion = { country: { isoCode: 'US', @@ -21,7 +29,7 @@ const mockUserRegion: UserRegion = { template: 'XXX-XXX-XXXX', }, currency: 'USD', - supported: true, + supported: { buy: true, sell: true }, }, state: { stateId: 'CA', name: 'California' }, regionCode: 'us-ca', @@ -58,14 +66,6 @@ const mockProviders: RampProvider[] = [ }, ]; -jest.mock('../../../../core/Engine', () => ({ - context: { - RampsController: { - getProviders: jest.fn().mockResolvedValue({ providers: mockProviders }), - }, - }, -})); - const createMockStore = (rampsControllerState = {}) => configureStore({ reducer: { @@ -74,6 +74,7 @@ const createMockStore = (rampsControllerState = {}) => RampsController: { userRegion: null, providers: [], + selectedProvider: null, requests: {}, ...rampsControllerState, }, @@ -90,23 +91,21 @@ const wrapper = (store: ReturnType) => describe('useRampsProviders', () => { beforeEach(() => { jest.clearAllMocks(); - ( - Engine.context.RampsController.getProviders as jest.Mock - ).mockResolvedValue({ providers: mockProviders }); }); describe('return value structure', () => { - it('returns providers, isLoading, error, and fetchProviders', () => { + it('returns providers, selectedProvider, setSelectedProvider, isLoading, and error', () => { const store = createMockStore(); const { result } = renderHook(() => useRampsProviders(), { wrapper: wrapper(store), }); expect(result.current).toMatchObject({ providers: [], + selectedProvider: null, isLoading: false, error: null, }); - expect(typeof result.current.fetchProviders).toBe('function'); + expect(typeof result.current.setSelectedProvider).toBe('function'); }); }); @@ -253,88 +252,53 @@ describe('useRampsProviders', () => { }); }); - describe('fetchProviders', () => { - it('calls getProviders with region when provided', async () => { - const store = createMockStore(); + describe('selectedProvider state', () => { + it('returns selectedProvider from state', () => { + const store = createMockStore({ selectedProvider: mockProviders[0] }); const { result } = renderHook(() => useRampsProviders(), { wrapper: wrapper(store), }); - await result.current.fetchProviders('us-ny'); - expect(Engine.context.RampsController.getProviders).toHaveBeenCalledWith( - 'us-ny', - undefined, - ); + expect(result.current.selectedProvider).toEqual(mockProviders[0]); }); - it('calls getProviders with options when provided', async () => { + it('returns null when selectedProvider is not available', () => { const store = createMockStore(); const { result } = renderHook(() => useRampsProviders(), { wrapper: wrapper(store), }); - await result.current.fetchProviders('us-ca', { - forceRefresh: true, - provider: 'provider-1', - crypto: 'ETH', - }); - expect(Engine.context.RampsController.getProviders).toHaveBeenCalledWith( - 'us-ca', - { - forceRefresh: true, - provider: 'provider-1', - crypto: 'ETH', - }, - ); + expect(result.current.selectedProvider).toBeNull(); }); + }); - it('returns providers data', async () => { + describe('setSelectedProvider', () => { + it('calls Engine.context.RampsController.setSelectedProvider with provider id', () => { const store = createMockStore(); const { result } = renderHook(() => useRampsProviders(), { wrapper: wrapper(store), }); - const response = await result.current.fetchProviders('us-ca'); - expect(response).toEqual({ providers: mockProviders }); - }); - it('uses regionCode fallback when fetchRegion is not provided', async () => { - const store = createMockStore({ - userRegion: mockUserRegion, + act(() => { + result.current.setSelectedProvider(mockProviders[0]); }); - const { result } = renderHook(() => useRampsProviders(), { - wrapper: wrapper(store), - }); - await result.current.fetchProviders(); - expect(Engine.context.RampsController.getProviders).toHaveBeenCalledWith( - 'us-ca', - undefined, - ); - }); - it('uses provided region parameter as fallback when fetchRegion is not provided', async () => { - const store = createMockStore(); - const { result } = renderHook(() => useRampsProviders('us-ny'), { - wrapper: wrapper(store), - }); - await result.current.fetchProviders(); - expect(Engine.context.RampsController.getProviders).toHaveBeenCalledWith( - 'us-ny', - undefined, - ); + expect( + Engine.context.RampsController.setSelectedProvider, + ).toHaveBeenCalledWith(mockProviders[0].id); }); - it('rejects with error when getProviders fails', async () => { + it('calls Engine.context.RampsController.setSelectedProvider with null when provider is null', () => { const store = createMockStore(); - const mockGetProviders = Engine.context.RampsController - .getProviders as jest.Mock; - mockGetProviders.mockReset(); - mockGetProviders.mockRejectedValue(new Error('Network error')); - const { result } = renderHook(() => useRampsProviders(), { wrapper: wrapper(store), }); - await expect(result.current.fetchProviders()).rejects.toThrow( - 'Network error', - ); + act(() => { + result.current.setSelectedProvider(null); + }); + + expect( + Engine.context.RampsController.setSelectedProvider, + ).toHaveBeenCalledWith(null); }); }); }); diff --git a/app/components/UI/Ramp/hooks/useRampsProviders.ts b/app/components/UI/Ramp/hooks/useRampsProviders.ts index 4a59f526c0e..b99e216f79f 100644 --- a/app/components/UI/Ramp/hooks/useRampsProviders.ts +++ b/app/components/UI/Ramp/hooks/useRampsProviders.ts @@ -1,15 +1,16 @@ import { useCallback, useMemo } from 'react'; import { useSelector } from 'react-redux'; -import Engine from '../../../../core/Engine'; import { selectProviders, selectProvidersRequest, + selectSelectedProvider, + selectUserRegion, } from '../../../../selectors/rampsController'; import { - ExecuteRequestOptions, RequestSelectorResult, type Provider, } from '@metamask/ramps-controller'; +import Engine from '../../../../core/Engine'; /** * Result returned by the useRampsProviders hook. @@ -19,6 +20,15 @@ export interface UseRampsProvidersResult { * The list of providers available for the current region. */ providers: Provider[]; + /** + * The currently selected provider, or null if none selected. + */ + selectedProvider: Provider | null; + /** + * Sets the selected provider. + * @param provider - The provider to select, or null to clear selection. + */ + setSelectedProvider: (provider: Provider | null) => void; /** * Whether the providers request is currently loading. */ @@ -27,18 +37,6 @@ export interface UseRampsProvidersResult { * The error message if the request failed, or null. */ error: string | null; - /** - * Fetch providers for a given region. - */ - fetchProviders: ( - region?: string, - options?: ExecuteRequestOptions & { - provider?: string | string[]; - crypto?: string | string[]; - fiat?: string | string[]; - payments?: string | string[]; - }, - ) => Promise<{ providers: Provider[] }>; } /** @@ -47,7 +45,7 @@ export interface UseRampsProvidersResult { * * @param region - Optional region code to use for request state. If not provided, uses userRegion from state. * @param filterOptions - Optional filter options for the request cache key. - * @returns Providers state and fetch function. + * @returns Providers state. */ export function useRampsProviders( region?: string, @@ -59,10 +57,8 @@ export function useRampsProviders( }, ): UseRampsProvidersResult { const providers = useSelector(selectProviders); - const userRegion = useSelector( - (state: Parameters[0]) => - state.engine.backgroundState.RampsController?.userRegion, - ); + const selectedProvider = useSelector(selectSelectedProvider); + const userRegion = useSelector(selectUserRegion); const regionCode = useMemo( () => region ?? userRegion?.regionCode ?? '', @@ -78,28 +74,20 @@ export function useRampsProviders( requestSelector, ) as RequestSelectorResult<{ providers: Provider[] }>; - const fetchProviders = useCallback( - async ( - fetchRegion?: string, - options?: ExecuteRequestOptions & { - provider?: string | string[]; - crypto?: string | string[]; - fiat?: string | string[]; - payments?: string | string[]; - }, - ) => - await Engine.context.RampsController.getProviders( - fetchRegion ?? regionCode, - options, - ), - [regionCode], - ); + const setSelectedProvider = useCallback((provider: Provider | null) => { + ( + Engine.context.RampsController.setSelectedProvider as ( + providerId: string | null, + ) => void + )(provider?.id ?? null); + }, []); return { providers, + selectedProvider, + setSelectedProvider, isLoading: isFetching, error, - fetchProviders, }; } diff --git a/app/components/UI/Ramp/hooks/useRampsTokens.test.ts b/app/components/UI/Ramp/hooks/useRampsTokens.test.ts index 15ed99f7b09..c5e84488c8d 100644 --- a/app/components/UI/Ramp/hooks/useRampsTokens.test.ts +++ b/app/components/UI/Ramp/hooks/useRampsTokens.test.ts @@ -1,4 +1,4 @@ -import { renderHook } from '@testing-library/react-native'; +import { renderHook, act } from '@testing-library/react-native'; import { Provider } from 'react-redux'; import { configureStore } from '@reduxjs/toolkit'; import React from 'react'; @@ -6,6 +6,14 @@ import { useRampsTokens } from './useRampsTokens'; import { RequestStatus, type UserRegion } from '@metamask/ramps-controller'; import Engine from '../../../../core/Engine'; +jest.mock('../../../../core/Engine', () => ({ + context: { + RampsController: { + setSelectedToken: jest.fn(), + }, + }, +})); + const mockUserRegion: UserRegion = { country: { isoCode: 'US', @@ -17,12 +25,22 @@ const mockUserRegion: UserRegion = { template: 'XXX-XXX-XXXX', }, currency: 'USD', - supported: true, + supported: { buy: true, sell: true }, }, state: { stateId: 'CA', name: 'California' }, regionCode: 'us-ca', }; +const mockSelectedToken = { + assetId: 'eip155:1/erc20:0x0000000000000000000000000000000000000000', + chainId: 'eip155:1', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + iconUrl: 'https://example.com/eth-icon.png', + tokenSupported: true, +}; + const mockTokens = { topTokens: [ { symbol: 'ETH', name: 'Ethereum' }, @@ -35,14 +53,6 @@ const mockTokens = { ], }; -jest.mock('../../../../core/Engine', () => ({ - context: { - RampsController: { - getTokens: jest.fn().mockResolvedValue(mockTokens), - }, - }, -})); - const createMockStore = (rampsControllerState = {}) => configureStore({ reducer: { @@ -51,6 +61,7 @@ const createMockStore = (rampsControllerState = {}) => RampsController: { userRegion: null, tokens: null, + selectedToken: null, requests: {}, ...rampsControllerState, }, @@ -67,23 +78,21 @@ const wrapper = (store: ReturnType) => describe('useRampsTokens', () => { beforeEach(() => { jest.clearAllMocks(); - (Engine.context.RampsController.getTokens as jest.Mock).mockResolvedValue( - mockTokens, - ); }); describe('return value structure', () => { - it('returns tokens, isLoading, error, and fetchTokens', () => { + it('returns tokens, selectedToken, setSelectedToken, isLoading, and error', () => { const store = createMockStore(); const { result } = renderHook(() => useRampsTokens(), { wrapper: wrapper(store), }); expect(result.current).toMatchObject({ tokens: null, + selectedToken: null, isLoading: false, error: null, }); - expect(typeof result.current.fetchTokens).toBe('function'); + expect(typeof result.current.setSelectedToken).toBe('function'); }); }); @@ -248,84 +257,38 @@ describe('useRampsTokens', () => { }); }); - describe('fetchTokens', () => { - it('calls getTokens with hook region and action when called without arguments', async () => { - const store = createMockStore({ - userRegion: mockUserRegion, - }); - const { result } = renderHook(() => useRampsTokens(undefined, 'sell'), { - wrapper: wrapper(store), - }); - await result.current.fetchTokens(); - expect(Engine.context.RampsController.getTokens).toHaveBeenCalledWith( - 'us-ca', - 'sell', - undefined, - ); - }); - - it('calls getTokens with provided region', async () => { - const store = createMockStore(); - const { result } = renderHook(() => useRampsTokens('us-ny', 'buy'), { - wrapper: wrapper(store), - }); - await result.current.fetchTokens('us-tx'); - expect(Engine.context.RampsController.getTokens).toHaveBeenCalledWith( - 'us-tx', - 'buy', - undefined, - ); - }); - - it('calls getTokens with provided action', async () => { - const store = createMockStore(); - const { result } = renderHook(() => useRampsTokens('us-ca', 'buy'), { + describe('selectedToken state', () => { + it('returns selectedToken from state', () => { + const store = createMockStore({ selectedToken: mockSelectedToken }); + const { result } = renderHook(() => useRampsTokens(), { wrapper: wrapper(store), }); - await result.current.fetchTokens(undefined, 'sell'); - expect(Engine.context.RampsController.getTokens).toHaveBeenCalledWith( - 'us-ca', - 'sell', - undefined, - ); + expect(result.current.selectedToken).toEqual(mockSelectedToken); }); - it('calls getTokens with options when provided', async () => { + it('returns null when selectedToken is not available', () => { const store = createMockStore(); const { result } = renderHook(() => useRampsTokens(), { wrapper: wrapper(store), }); - await result.current.fetchTokens('us-ca', 'buy', { forceRefresh: true }); - expect(Engine.context.RampsController.getTokens).toHaveBeenCalledWith( - 'us-ca', - 'buy', - { forceRefresh: true }, - ); + expect(result.current.selectedToken).toBeNull(); }); + }); - it('returns tokens data', async () => { + describe('setSelectedToken', () => { + it('calls Engine.context.RampsController.setSelectedToken with assetId', () => { const store = createMockStore(); const { result } = renderHook(() => useRampsTokens(), { wrapper: wrapper(store), }); - const tokens = await result.current.fetchTokens('us-ca'); - expect(tokens).toEqual(mockTokens); - }); - it('rejects with error when getTokens fails', async () => { - const store = createMockStore(); - const mockGetTokens = Engine.context.RampsController - .getTokens as jest.Mock; - mockGetTokens.mockReset(); - mockGetTokens.mockRejectedValue(new Error('Network error')); - - const { result } = renderHook(() => useRampsTokens(), { - wrapper: wrapper(store), + act(() => { + result.current.setSelectedToken(mockSelectedToken.assetId); }); - await expect(result.current.fetchTokens()).rejects.toThrow( - 'Network error', - ); + expect( + Engine.context.RampsController.setSelectedToken, + ).toHaveBeenCalledWith(mockSelectedToken.assetId); }); }); }); diff --git a/app/components/UI/Ramp/hooks/useRampsTokens.ts b/app/components/UI/Ramp/hooks/useRampsTokens.ts index b7d68583c46..4174d143cf4 100644 --- a/app/components/UI/Ramp/hooks/useRampsTokens.ts +++ b/app/components/UI/Ramp/hooks/useRampsTokens.ts @@ -1,17 +1,19 @@ import { useCallback, useMemo } from 'react'; import { useSelector } from 'react-redux'; -import Engine from '../../../../core/Engine'; import { selectTokens, selectTokensRequest, + selectSelectedToken, + selectUserRegion, } from '../../../../selectors/rampsController'; import { - ExecuteRequestOptions, RequestSelectorResult, type RampsControllerState, } from '@metamask/ramps-controller'; +import Engine from '../../../../core/Engine'; type TokensResponse = NonNullable; +type SelectedToken = RampsControllerState['selectedToken']; /** * Result returned by the useRampsTokens hook. @@ -21,6 +23,15 @@ export interface UseRampsTokensResult { * The tokens response containing topTokens and allTokens, or null if not loaded. */ tokens: TokensResponse | null; + /** + * The currently selected token, or null if none selected. + */ + selectedToken: SelectedToken; + /** + * Sets the selected token by asset ID. + * @param assetId - The asset identifier in CAIP-19 format (e.g., "eip155:1/erc20:0x...") + */ + setSelectedToken: (assetId?: string) => void; /** * Whether the tokens request is currently loading. */ @@ -29,14 +40,6 @@ export interface UseRampsTokensResult { * The error message if the request failed, or null. */ error: string | null; - /** - * Fetch tokens for a given region and action. - */ - fetchTokens: ( - region?: string, - action?: 'buy' | 'sell', - options?: ExecuteRequestOptions, - ) => Promise; } /** @@ -45,17 +48,15 @@ export interface UseRampsTokensResult { * * @param region - Optional region code to use for request state. If not provided, uses userRegion from state. * @param action - Optional action type ('buy' or 'sell'). Defaults to 'buy'. - * @returns Tokens state and fetch function. + * @returns Tokens state. */ export function useRampsTokens( region?: string, action: 'buy' | 'sell' = 'buy', ): UseRampsTokensResult { const tokens = useSelector(selectTokens); - const userRegion = useSelector( - (state: Parameters[0]) => - state.engine.backgroundState.RampsController?.userRegion, - ); + const selectedToken = useSelector(selectSelectedToken); + const userRegion = useSelector(selectUserRegion); const regionCode = useMemo( () => region ?? userRegion?.regionCode ?? '', @@ -71,25 +72,18 @@ export function useRampsTokens( requestSelector, ) as RequestSelectorResult; - const fetchTokens = useCallback( - async ( - fetchRegion?: string, - fetchAction: 'buy' | 'sell' = action, - options?: ExecuteRequestOptions, - ) => - await Engine.context.RampsController.getTokens( - fetchRegion ?? regionCode, - fetchAction, - options, - ), - [action, regionCode], + const setSelectedToken = useCallback( + (assetId?: string) => + Engine.context.RampsController.setSelectedToken(assetId), + [], ); return { tokens, + selectedToken, + setSelectedToken, isLoading: isFetching, error, - fetchTokens, }; } diff --git a/app/components/UI/Ramp/hooks/useRampsUserRegion.test.ts b/app/components/UI/Ramp/hooks/useRampsUserRegion.test.ts index 5b6768aa1c1..89b2d3432a2 100644 --- a/app/components/UI/Ramp/hooks/useRampsUserRegion.test.ts +++ b/app/components/UI/Ramp/hooks/useRampsUserRegion.test.ts @@ -17,7 +17,7 @@ const mockUserRegion: UserRegion = { template: 'XXX-XXX-XXXX', }, currency: 'USD', - supported: true, + supported: { buy: true, sell: true }, }, state: { stateId: 'CA', name: 'California' }, regionCode: 'us-ca', @@ -35,7 +35,7 @@ jest.mock('../../../../core/Engine', () => { template: 'XXX-XXX-XXXX', }, currency: 'USD', - supported: true, + supported: { buy: true, sell: true }, }, state: { stateId: 'CA', name: 'California' }, regionCode: 'us-ca', @@ -44,7 +44,7 @@ jest.mock('../../../../core/Engine', () => { return { context: { RampsController: { - updateUserRegion: jest.fn().mockResolvedValue(mockUserRegionValue), + init: jest.fn().mockResolvedValue(mockUserRegionValue), setUserRegion: jest.fn().mockResolvedValue(mockUserRegionValue), }, }, @@ -106,7 +106,7 @@ describe('useRampsUserRegion', () => { it('returns isLoading true when request is loading', () => { const store = createMockStore({ requests: { - 'updateUserRegion:[]': { + 'init:[]': { status: RequestStatus.LOADING, data: null, error: null, @@ -126,7 +126,7 @@ describe('useRampsUserRegion', () => { it('returns error from request state', () => { const store = createMockStore({ requests: { - 'updateUserRegion:[]': { + 'init:[]': { status: RequestStatus.ERROR, data: null, error: 'Network error', @@ -143,49 +143,44 @@ describe('useRampsUserRegion', () => { }); describe('fetchUserRegion', () => { - it('calls updateUserRegion without options when called with no arguments', async () => { + it('calls init without options when called with no arguments', async () => { const store = createMockStore(); const { result } = renderHook(() => useRampsUserRegion(), { wrapper: wrapper(store), }); await result.current.fetchUserRegion(); - expect( - Engine.context.RampsController.updateUserRegion, - ).toHaveBeenCalledWith(undefined); + expect(Engine.context.RampsController.init).toHaveBeenCalledWith( + undefined, + ); }); - it('calls updateUserRegion with forceRefresh true when specified', async () => { + it('calls init with forceRefresh true when specified', async () => { const store = createMockStore(); const { result } = renderHook(() => useRampsUserRegion(), { wrapper: wrapper(store), }); await result.current.fetchUserRegion({ forceRefresh: true }); - expect( - Engine.context.RampsController.updateUserRegion, - ).toHaveBeenCalledWith({ + expect(Engine.context.RampsController.init).toHaveBeenCalledWith({ forceRefresh: true, }); }); - it('calls updateUserRegion with forceRefresh false when specified', async () => { + it('calls init with forceRefresh false when specified', async () => { const store = createMockStore(); const { result } = renderHook(() => useRampsUserRegion(), { wrapper: wrapper(store), }); await result.current.fetchUserRegion({ forceRefresh: false }); - expect( - Engine.context.RampsController.updateUserRegion, - ).toHaveBeenCalledWith({ + expect(Engine.context.RampsController.init).toHaveBeenCalledWith({ forceRefresh: false, }); }); - it('rejects with error when updateUserRegion fails', async () => { + it('rejects with error when init fails', async () => { const store = createMockStore(); - const mockUpdateUserRegion = Engine.context.RampsController - .updateUserRegion as jest.Mock; - mockUpdateUserRegion.mockReset(); - mockUpdateUserRegion.mockRejectedValue(new Error('Network error')); + const mockInit = Engine.context.RampsController.init as jest.Mock; + mockInit.mockReset(); + mockInit.mockRejectedValue(new Error('Network error')); const { result } = renderHook(() => useRampsUserRegion(), { wrapper: wrapper(store), @@ -226,17 +221,16 @@ describe('useRampsUserRegion', () => { describe('useEffect error handling', () => { it('returns default state when fetchUserRegion rejects in useEffect', async () => { const store = createMockStore(); - const mockUpdateUserRegion = Engine.context.RampsController - .updateUserRegion as jest.Mock; - mockUpdateUserRegion.mockReset(); - mockUpdateUserRegion.mockRejectedValue(new Error('Fetch failed')); + const mockInit = Engine.context.RampsController.init as jest.Mock; + mockInit.mockReset(); + mockInit.mockRejectedValue(new Error('Fetch failed')); const { result } = renderHook(() => useRampsUserRegion(), { wrapper: wrapper(store), }); await waitFor(() => { - expect(mockUpdateUserRegion).toHaveBeenCalled(); + expect(mockInit).toHaveBeenCalled(); }); expect(result.current).toMatchObject({ diff --git a/app/components/UI/Ramp/hooks/useRampsUserRegion.ts b/app/components/UI/Ramp/hooks/useRampsUserRegion.ts index 81bdde37d70..4c8745531bf 100644 --- a/app/components/UI/Ramp/hooks/useRampsUserRegion.ts +++ b/app/components/UI/Ramp/hooks/useRampsUserRegion.ts @@ -30,9 +30,7 @@ export interface UseRampsUserRegionResult { /** * Manually fetch the user region from geolocation. */ - fetchUserRegion: ( - options?: ExecuteRequestOptions, - ) => Promise; + fetchUserRegion: (options?: ExecuteRequestOptions) => Promise; /** * Set the user region manually (without fetching geolocation). */ @@ -56,7 +54,7 @@ export function useRampsUserRegion(): UseRampsUserRegionResult { const fetchUserRegion = useCallback( async (options?: ExecuteRequestOptions) => - await Engine.context.RampsController.updateUserRegion(options), + await Engine.context.RampsController.init(options), [], ); diff --git a/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonLevel.tsx b/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonLevel.tsx index a2871b17e53..4e623dd303d 100644 --- a/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonLevel.tsx +++ b/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonLevel.tsx @@ -36,7 +36,11 @@ const PreviousSeasonLevel: React.FC = () => { > {/* Tier Image */} - + diff --git a/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonReferralDetails.tsx b/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonReferralDetails.tsx index ec8be1ffb65..7dce9260c34 100644 --- a/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonReferralDetails.tsx +++ b/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonReferralDetails.tsx @@ -54,7 +54,11 @@ const PreviousSeasonReferralDetails = () => { loadingHeight={72} > - + {totalReferees} diff --git a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/InteractiveTimespanChart.constants.ts b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/InteractiveTimespanChart.constants.ts index f33ff8b84cc..a5e82adc273 100644 --- a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/InteractiveTimespanChart.constants.ts +++ b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/InteractiveTimespanChart.constants.ts @@ -1,4 +1,3 @@ -import { colors } from '@metamask/design-tokens'; import { GraphOptions } from './InteractiveTimespanChart.types'; import { ChartButton } from './ChartTimespanButtonGroup/ChartTimespanButtonGroup.types'; import { strings } from '../../../../../../../locales/i18n'; @@ -18,11 +17,10 @@ export const CHART_BUTTONS: ChartButton[] = [ const DEFAULT_INSET = 0; -export const DEFAULT_GRAPH_OPTIONS: GraphOptions = { +export const DEFAULT_GRAPH_OPTIONS: Omit = { insetTop: DEFAULT_INSET, insetRight: DEFAULT_INSET, insetBottom: DEFAULT_INSET, insetLeft: DEFAULT_INSET, timespanButtons: CHART_BUTTONS, - color: colors.light.success.default, }; diff --git a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/InteractiveTimespanChart.test.tsx b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/InteractiveTimespanChart.test.tsx index 10ef79e92fe..35116d99121 100644 --- a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/InteractiveTimespanChart.test.tsx +++ b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/InteractiveTimespanChart.test.tsx @@ -168,7 +168,10 @@ describe('InteractiveTimespanChart', () => { const customColor = 'red'; const { findByText, getByTestId } = renderGraph('object', { - graphOptions: { ...DEFAULT_GRAPH_OPTIONS, color: customColor }, + graphOptions: { + ...DEFAULT_GRAPH_OPTIONS, + color: customColor, + }, }); const titleText = await findByText(DEFAULT_PROPS.OBJECT_ARRAY.title); diff --git a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/__snapshots__/InteractiveTimespanChart.test.tsx.snap b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/__snapshots__/InteractiveTimespanChart.test.tsx.snap index f20beff1b5c..200542c4430 100644 --- a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/__snapshots__/InteractiveTimespanChart.test.tsx.snap +++ b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/__snapshots__/InteractiveTimespanChart.test.tsx.snap @@ -117,7 +117,7 @@ exports[`InteractiveTimespanChart render matches snapshot 1`] = ` accessibilityRole="text" style={ { - "color": "#457a39", + "color": "#121314", "fontFamily": "Geist-Bold", "fontSize": 24, "letterSpacing": 0, @@ -233,7 +233,7 @@ exports[`InteractiveTimespanChart render matches snapshot 1`] = ` } stroke={ { - "payload": 4282743353, + "payload": 4279374612, "type": 0, } } @@ -245,7 +245,7 @@ exports[`InteractiveTimespanChart render matches snapshot 1`] = ` gradient={ [ 0, - 860191289, + 856822548, 1, 872415231, ] @@ -384,7 +384,7 @@ exports[`InteractiveTimespanChart renders no title or subtitle when defaultTitle accessibilityRole="text" style={ { - "color": "#457a39", + "color": "#121314", "fontFamily": "Geist-Bold", "fontSize": 24, "letterSpacing": 0, @@ -496,7 +496,7 @@ exports[`InteractiveTimespanChart renders no title or subtitle when defaultTitle } stroke={ { - "payload": 4282743353, + "payload": 4279374612, "type": 0, } } @@ -508,7 +508,7 @@ exports[`InteractiveTimespanChart renders no title or subtitle when defaultTitle gradient={ [ 0, - 860191289, + 856822548, 1, 872415231, ] @@ -647,7 +647,7 @@ exports[`InteractiveTimespanChart renders only the subtitle when title props are accessibilityRole="text" style={ { - "color": "#457a39", + "color": "#121314", "fontFamily": "Geist-Bold", "fontSize": 24, "letterSpacing": 0, @@ -761,7 +761,7 @@ exports[`InteractiveTimespanChart renders only the subtitle when title props are } stroke={ { - "payload": 4282743353, + "payload": 4279374612, "type": 0, } } @@ -773,7 +773,7 @@ exports[`InteractiveTimespanChart renders only the subtitle when title props are gradient={ [ 0, - 860191289, + 856822548, 1, 872415231, ] @@ -912,7 +912,7 @@ exports[`InteractiveTimespanChart renders only the title when subtitle props are accessibilityRole="text" style={ { - "color": "#457a39", + "color": "#121314", "fontFamily": "Geist-Bold", "fontSize": 24, "letterSpacing": 0, @@ -1026,7 +1026,7 @@ exports[`InteractiveTimespanChart renders only the title when subtitle props are } stroke={ { - "payload": 4282743353, + "payload": 4279374612, "type": 0, } } @@ -1038,7 +1038,7 @@ exports[`InteractiveTimespanChart renders only the title when subtitle props are gradient={ [ 0, - 860191289, + 856822548, 1, 872415231, ] @@ -1177,7 +1177,7 @@ exports[`InteractiveTimespanChart supports dataPoints as number[] 1`] = ` accessibilityRole="text" style={ { - "color": "#457a39", + "color": "#121314", "fontFamily": "Geist-Bold", "fontSize": 24, "letterSpacing": 0, @@ -1293,7 +1293,7 @@ exports[`InteractiveTimespanChart supports dataPoints as number[] 1`] = ` } stroke={ { - "payload": 4282743353, + "payload": 4279374612, "type": 0, } } @@ -1305,7 +1305,7 @@ exports[`InteractiveTimespanChart supports dataPoints as number[] 1`] = ` gradient={ [ 0, - 860191289, + 856822548, 1, 872415231, ] @@ -1444,7 +1444,7 @@ exports[`InteractiveTimespanChart supports dataPoints as object[] 1`] = ` accessibilityRole="text" style={ { - "color": "#457a39", + "color": "#121314", "fontFamily": "Geist-Bold", "fontSize": 24, "letterSpacing": 0, @@ -1560,7 +1560,7 @@ exports[`InteractiveTimespanChart supports dataPoints as object[] 1`] = ` } stroke={ { - "payload": 4282743353, + "payload": 4279374612, "type": 0, } } @@ -1572,7 +1572,7 @@ exports[`InteractiveTimespanChart supports dataPoints as object[] 1`] = ` gradient={ [ 0, - 860191289, + 856822548, 1, 872415231, ] diff --git a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/index.tsx b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/index.tsx index 366fc414901..0a029383e89 100644 --- a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/index.tsx +++ b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/InteractiveTimespanChart/index.tsx @@ -11,6 +11,7 @@ import DataGradient from './DataGradient'; import PlotLine from './PlotLine'; import styleSheet from './InteractiveTimespanChart.styles'; import { useStyles } from '../../../../../hooks/useStyles'; +import { useTheme } from '../../../../../../util/theme'; import { calculateSegmentCenters, calculateSnapThreshold, @@ -93,19 +94,15 @@ const InteractiveTimespanChart = ({ isLoading = false, }: InteractiveTimespanChartProps) => { const { styles } = useStyles(styleSheet, {}); + const { colors } = useTheme(); - const { - insetTop, - insetRight, - insetBottom, - insetLeft, - timespanButtons, - color, - } = { + const { insetTop, insetRight, insetBottom, insetLeft, timespanButtons } = { ...DEFAULT_GRAPH_OPTIONS, ...graphOptions, }; + const chartColor = graphOptions?.color ?? colors.icon.default; + const [dataPointsToShow, setDataPointsToShow] = useState( dataPoints.slice(-timespanButtons[0].value), ); @@ -244,14 +241,17 @@ const InteractiveTimespanChart = ({ svg={doesChartHaveData ? { fill: `url(#dataGradient)` } : undefined} yMin={0} > - + {doesChartHaveData && ( - + )} @@ -271,7 +271,7 @@ const InteractiveTimespanChart = ({ subtitle={ parsedSubtitleValues[selectedPointIndex] ?? defaultSubtitle ?? '' } - color={color} + color={chartColor} isLoading={isLoading} /> )} diff --git a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/PoolStakingLearnMoreModal.styles.ts b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/PoolStakingLearnMoreModal.styles.ts index 2ecad00d77f..1d89b2e5721 100644 --- a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/PoolStakingLearnMoreModal.styles.ts +++ b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/PoolStakingLearnMoreModal.styles.ts @@ -1,4 +1,4 @@ -import { StyleSheet } from 'react-native'; +import { Platform, StyleSheet } from 'react-native'; const styleSheet = () => StyleSheet.create({ @@ -10,7 +10,8 @@ const styleSheet = () => fontStyle: 'italic', }, footer: { - paddingVertical: 16, + paddingTop: 24, + paddingBottom: Platform.OS === 'android' ? 0 : 16, paddingHorizontal: 16, }, }); diff --git a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/__snapshots__/PoolStakingLearnMoreModal.test.tsx.snap b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/__snapshots__/PoolStakingLearnMoreModal.test.tsx.snap index 46b55d04369..310c9a5489b 100644 --- a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/__snapshots__/PoolStakingLearnMoreModal.test.tsx.snap +++ b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/__snapshots__/PoolStakingLearnMoreModal.test.tsx.snap @@ -121,11 +121,11 @@ exports[`PoolStakingLearnMoreModal render matches snapshot 1`] = ` "flexDirection": "row", "gap": 16, "height": 56, + "paddingLeft": 8, + "paddingRight": 8, }, false, - { - "paddingHorizontal": 16, - }, + undefined, ] } testID="header" @@ -149,57 +149,120 @@ exports[`PoolStakingLearnMoreModal render matches snapshot 1`] = ` ] } > - - Stake ETH and earn - + + Stake ETH and earn + + - - - + accessible={true} + collapsable={false} + focusable={true} + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + style={ + [ + { + "alignItems": "center", + "backgroundColor": "transparent", + "borderRadius": 2, + "height": 32, + "justifyContent": "center", + "opacity": 1, + "width": 32, + }, + undefined, + ] + } + testID="button-icon" + > + + + @@ -613,8 +676,10 @@ exports[`PoolStakingLearnMoreModal render matches snapshot 1`] = ` style={ { "flexDirection": "row", + "paddingBottom": 16, "paddingHorizontal": 16, - "paddingVertical": 16, + "paddingTop": 24, + "paddingVertical": 4, } } testID="bottomsheetfooter" diff --git a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/index.tsx b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/index.tsx index fe0a3c42463..4e35bbc2d0f 100644 --- a/app/components/UI/Stake/components/PoolStakingLearnMoreModal/index.tsx +++ b/app/components/UI/Stake/components/PoolStakingLearnMoreModal/index.tsx @@ -2,11 +2,10 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import BottomSheet, { BottomSheetRef, } from '../../../../../component-library/components/BottomSheets/BottomSheet'; -import Text, { - TextVariant, -} from '../../../../../component-library/components/Texts/Text'; import { ScrollView } from 'react-native'; -import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import HeaderCenter from '../../../../../component-library/components-temp/HeaderCenter'; +import { useTheme } from '../../../../../util/theme'; + import { useRoute, RouteProp } from '@react-navigation/native'; import { POOLED_STAKING_FAQ_URL } from '../../constants'; import styleSheet from './PoolStakingLearnMoreModal.styles'; @@ -47,6 +46,7 @@ type PoolStakingLearnMoreModalRouteProp = RouteProp< const PoolStakingLearnMoreModal = () => { const { styles } = useStyles(styleSheet, {}); + const { colors } = useTheme(); const route = useRoute(); const { chainId: routeChainId } = route.params; @@ -125,11 +125,10 @@ const PoolStakingLearnMoreModal = () => { return ( - - - {strings('stake.stake_eth_and_earn')} - - + {Boolean(reversedVaultApys.length) && activeTimespanApyAverage && ( { ...getGraphInsetsByDataPointLength( activeTimespanApyAverage.numDays, ), + color: colors.success.default, }} isLoading={isLoadingVaultApyAverages || isLoadingVaultApys} /> diff --git a/app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx b/app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx index 735e68c8d61..3f5e0bb0b3d 100644 --- a/app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx +++ b/app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx @@ -21,62 +21,10 @@ import { } from '../../../Earn/selectors/featureFlags'; import { TokenI } from '../../../Tokens/types'; import { EARN_EXPERIENCES } from '../../../Earn/constants/experiences'; +import { MINIMUM_BALANCE_FOR_EARN_CTA } from '../../../Earn/constants/token'; const mockNavigate = jest.fn(); -const MOCK_APR_VALUES: { [symbol: string]: string } = { - Ethereum: '2.3', - USDC: '4.5', - USDT: '4.1', - DAI: '5.0', -}; - -const mockGetEarnToken = jest.fn((token: TokenI) => { - const experienceType = - token.symbol === 'USDC' - ? EARN_EXPERIENCES.STABLECOIN_LENDING - : EARN_EXPERIENCES.POOLED_STAKING; - - const experiences = [ - { - type: experienceType as EARN_EXPERIENCES, - apr: MOCK_APR_VALUES?.[token.symbol] ?? '', - estimatedAnnualRewardsFormatted: '', - estimatedAnnualRewardsFiatNumber: 0, - }, - ]; - - const baseEarnToken = { - ...token, - balanceFormatted: token.symbol === 'USDC' ? '6.84314 USDC' : '0', - balanceFiat: token.symbol === 'USDC' ? '$6.84' : '$0.00', - balanceMinimalUnit: token.symbol === 'USDC' ? '6.84314' : '0', - balanceFiatNumber: token.symbol === 'USDC' ? 6.84314 : 0, - }; - - const adjustedEarnToken = - token.symbol === 'TRX' - ? { - ...baseEarnToken, - balanceMinimalUnit: '1', - } - : baseEarnToken; - - return { - ...adjustedEarnToken, - experiences, - tokenUsdExchangeRate: 0, - experience: experiences[0], - }; -}); - -jest.mock('../../../Earn/hooks/useEarnTokens', () => ({ - __esModule: true, - default: () => ({ - getEarnToken: (token: TokenI) => mockGetEarnToken(token), - }), -})); - jest.mock('@react-navigation/native', () => { const actualReactNavigation = jest.requireActual('@react-navigation/native'); return { @@ -115,6 +63,19 @@ jest.mock('../../../../../selectors/earnController/earn', () => ({ selectPrimaryEarnExperienceTypeForAsset: jest.fn((_state, asset) => asset.symbol === 'USDC' ? 'STABLECOIN_LENDING' : 'POOLED_STAKING', ), + selectEarnToken: jest.fn((_state, asset) => { + const balanceFiatNumber = Number(asset?.balance ?? '0') || 0; + + return { + ...asset, + // `StakeButton` checks this value against `MINIMUM_BALANCE_FOR_EARN_CTA` + balanceFiatNumber, + // Ensure ETH-specific conditions behave consistently + isETH: asset?.symbol === 'ETH' || asset?.isETH, + }; + }), + selectEarnOutputToken: jest.fn(() => undefined), + selectEarnTokenPair: jest.fn(() => undefined), }, })); @@ -218,11 +179,38 @@ const STATE_MOCK = { }, } as unknown as RootState; -const renderComponent = (state = STATE_MOCK) => - renderWithProvider(, { - state, +const MOCK_MINIMUM_BALANCE_AS_STRING = String(MINIMUM_BALANCE_FOR_EARN_CTA); + +const MOCK_ETH_MAINNET_ASSET_WITH_MINIMUM_BALANCE: TokenI = { + ...MOCK_ETH_MAINNET_ASSET, + balance: MOCK_MINIMUM_BALANCE_AS_STRING, +}; + +const MOCK_USDC_MAINNET_ASSET_WITH_MINIMUM_BALANCE: TokenI = { + ...MOCK_USDC_MAINNET_ASSET, + balance: MOCK_MINIMUM_BALANCE_AS_STRING, +}; + +const getExpectedNavigationToken = (token: TokenI) => + expect.objectContaining({ + address: token.address, + chainId: token.chainId, + symbol: token.symbol, + decimals: token.decimals, + isNative: token.isNative, + isETH: token.isETH, + balance: token.balance, + balanceFiatNumber: expect.any(Number), }); +const renderComponent = (state = STATE_MOCK) => + renderWithProvider( + , + { + state, + }, + ); + const selectPrimaryEarnExperienceTypeForAssetMock = jest.requireMock( '../../../../../selectors/earnController/earn', ).earnSelectors.selectPrimaryEarnExperienceTypeForAsset as jest.Mock; @@ -235,6 +223,17 @@ describe('StakeButton', () => { beforeEach(() => { jest.clearAllMocks(); + ( + selectPooledStakingEnabledFlag as jest.MockedFunction< + typeof selectPooledStakingEnabledFlag + > + ).mockReturnValue(true); + ( + selectStablecoinLendingEnabledFlag as jest.MockedFunction< + typeof selectStablecoinLendingEnabledFlag + > + ).mockReturnValue(true); + mockUseStakingEligibility.mockReturnValue({ isEligible: true, isLoadingEligibility: false, @@ -258,7 +257,9 @@ describe('StakeButton', () => { expect(mockNavigate).toHaveBeenCalledWith('StakeScreens', { screen: Routes.STAKING.STAKE, params: { - token: MOCK_ETH_MAINNET_ASSET, + token: getExpectedNavigationToken( + MOCK_ETH_MAINNET_ASSET_WITH_MINIMUM_BALANCE, + ), }, }); }); @@ -314,7 +315,9 @@ describe('StakeButton', () => { expect(mockNavigate).toHaveBeenCalledWith('StakeScreens', { screen: Routes.STAKING.STAKE, params: { - token: { ...MOCK_ETH_MAINNET_ASSET }, + token: getExpectedNavigationToken( + MOCK_ETH_MAINNET_ASSET_WITH_MINIMUM_BALANCE, + ), }, }); }); @@ -324,7 +327,7 @@ describe('StakeButton', () => { describe('Stablecoin Lending', () => { it('navigates to Lending Input View when earn button is pressed', async () => { const { getByTestId } = renderWithProvider( - , + , { state: STATE_MOCK, }, @@ -336,7 +339,9 @@ describe('StakeButton', () => { expect(mockNavigate).toHaveBeenCalledWith('StakeScreens', { screen: Routes.STAKING.STAKE, params: { - token: MOCK_USDC_MAINNET_ASSET, + token: getExpectedNavigationToken( + MOCK_USDC_MAINNET_ASSET_WITH_MINIMUM_BALANCE, + ), }, }); }); @@ -352,6 +357,7 @@ describe('StakeButton', () => { isNative: true, // Ensure ETH-specific logic does not apply isETH: false, + balance: MOCK_MINIMUM_BALANCE_AS_STRING, }; it('navigates to Stake Input screen when TRX has POOLED_STAKING experience', async () => { @@ -373,7 +379,7 @@ describe('StakeButton', () => { expect(mockNavigate).toHaveBeenCalledWith('StakeScreens', { screen: Routes.STAKING.STAKE, params: { - token: MOCK_TRX_ASSET, + token: getExpectedNavigationToken(MOCK_TRX_ASSET), }, }); }); @@ -426,4 +432,40 @@ describe('StakeButton', () => { expect(queryByTestId(WalletViewSelectorsIDs.STAKE_BUTTON)).toBeNull(); }); + + describe('Earn CTA minimum balance threshold', () => { + it('does not render button when asset balance is below minimum earn cta threshold', () => { + // Arrange + const asset = { ...MOCK_ETH_MAINNET_ASSET, balance: '0.009' }; + + // Act + const { queryByTestId } = renderWithProvider( + , + { + state: STATE_MOCK, + }, + ); + + // Assert + expect(queryByTestId(WalletViewSelectorsIDs.STAKE_BUTTON)).toBeNull(); + }); + + it('renders button when asset balance meets minimum earn cta threshold', () => { + // Arrange + const asset = MOCK_ETH_MAINNET_ASSET_WITH_MINIMUM_BALANCE; + + // Act + const { getByTestId } = renderWithProvider( + , + { + state: STATE_MOCK, + }, + ); + + // Assert + expect( + getByTestId(WalletViewSelectorsIDs.STAKE_BUTTON), + ).toBeOnTheScreen(); + }); + }); }); diff --git a/app/components/UI/Stake/components/StakeButton/index.tsx b/app/components/UI/Stake/components/StakeButton/index.tsx index b47b5647fd0..cd45166a9c5 100644 --- a/app/components/UI/Stake/components/StakeButton/index.tsx +++ b/app/components/UI/Stake/components/StakeButton/index.tsx @@ -19,7 +19,6 @@ import { import { getDecimalChainId } from '../../../../../util/networks'; import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics'; import { EARN_EXPERIENCES } from '../../../Earn/constants/experiences'; -import useEarnTokens from '../../../Earn/hooks/useEarnTokens'; import { selectPooledStakingEnabledFlag, selectStablecoinLendingEnabledFlag, @@ -38,6 +37,10 @@ import { isTronChainId } from '../../../../../core/Multichain/utils'; import useTronStakeApy from '../../../Earn/hooks/useTronStakeApy'; import useStakingEligibility from '../../hooks/useStakingEligibility'; ///: END:ONLY_INCLUDE_IF +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: { @@ -48,12 +51,13 @@ const styles = StyleSheet.create({ marginRight: 2, }, }); -interface StakeButtonProps { - asset: TokenI; + +interface StakeButtonContentProps { + earnToken: EarnTokenDetails; } // TODO: Rename to EarnCta to better describe this component's purpose. -const StakeButtonContent = ({ asset }: StakeButtonProps) => { +const StakeButtonContent = ({ earnToken }: StakeButtonContentProps) => { const navigation = useNavigation(); const { trackEvent, createEventBuilder } = useMetrics(); const chainId = useSelector(selectEvmChainId); @@ -66,18 +70,16 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => { ///: BEGIN:ONLY_INCLUDE_IF(tron) const isTrxStakingEnabled = useSelector(selectTrxStakingEnabled); - const isTronNative = asset?.isNative && isTronChainId(asset.chainId as Hex); + const isTronNative = + earnToken?.isNative && isTronChainId(earnToken.chainId as Hex); const { apyPercent: tronApyPercent } = useTronStakeApy(); ///: END:ONLY_INCLUDE_IF const network = useSelector((state: RootState) => - selectNetworkConfigurationByChainId(state, asset.chainId as Hex), + selectNetworkConfigurationByChainId(state, earnToken?.chainId as Hex), ); - const { getEarnToken } = useEarnTokens(); - const earnToken = getEarnToken(asset); - const primaryExperienceType = useSelector((state: RootState) => - earnSelectors.selectPrimaryEarnExperienceTypeForAsset(state, asset), + earnSelectors.selectPrimaryEarnExperienceTypeForAsset(state, earnToken), ); const areEarnExperiencesDisabled = @@ -90,11 +92,11 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => { trackEvent( createEventBuilder(MetaMetricsEvents.STAKE_BUTTON_CLICKED) .addProperties({ - chain_id: getDecimalChainId(asset.chainId as Hex), + chain_id: getDecimalChainId(earnToken.chainId as Hex), location: EVENT_LOCATIONS.HOME_SCREEN, action_type: 'deposit', text: 'Earn', - token: asset.symbol, + token: earnToken.symbol, network: network?.name, experience: EARN_EXPERIENCES.POOLED_STAKING, }) @@ -104,7 +106,7 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => { navigation.navigate('StakeScreens', { screen: Routes.STAKING.STAKE, params: { - token: asset, + token: earnToken, }, }); return; @@ -124,7 +126,7 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => { location: EVENT_LOCATIONS.HOME_SCREEN, action_type: 'deposit', text: 'Earn', - token: asset.symbol, + token: earnToken.symbol, network: network?.name, url: AppConstants.STAKE.URL, experience: EARN_EXPERIENCES.POOLED_STAKING, @@ -135,13 +137,13 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => { navigation.navigate('StakeScreens', { screen: Routes.STAKING.STAKE, params: { - token: asset, + token: earnToken, }, }); }; const handleLendingRedirect = useStablecoinLendingRedirect({ - asset, + asset: earnToken, location: EVENT_LOCATIONS.HOME_SCREEN, }); @@ -195,15 +197,29 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => { ); }; +interface StakeButtonProps { + asset: TokenI; +} + export const StakeButton = (props: StakeButtonProps) => { const { isEligible } = useStakingEligibility(); + const { earnToken } = useEarnToken(props.asset); + + if (!isEligible || !earnToken) { + return null; + } - if (!isEligible) { + if ( + new BigNumber(earnToken?.balanceFiatNumber || '0').lt( + MINIMUM_BALANCE_FOR_EARN_CTA, + ) + ) { return null; } + return ( - + ); }; diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx index 9fb1731c548..d97dddfa93f 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx @@ -19,8 +19,12 @@ import { strings } from '../../../../../../locales/i18n'; import { useMusdConversionTokens } from '../../../Earn/hooks/useMusdConversionTokens'; import { useMusdConversionEligibility } from '../../../Earn/hooks/useMusdConversionEligibility'; -import { selectIsMusdConversionFlowEnabledFlag } from '../../../Earn/selectors/featureFlags'; +import { + selectIsMusdConversionFlowEnabledFlag, + selectStablecoinLendingEnabledFlag, +} from '../../../Earn/selectors/featureFlags'; import { MUSD_CONVERSION_APY } from '../../../Earn/constants/musd'; +import { EARN_EXPERIENCES } from '../../../Earn/constants/experiences'; jest.mock('../../../Stake/components/StakeButton', () => ({ __esModule: true, @@ -83,9 +87,25 @@ jest.mock('../../hooks/useTokenPricePercentageChange', () => ({ useTokenPricePercentageChange: jest.fn(), })); +interface MockEarnToken { + balanceFiatNumber?: number; + experience?: { + type?: EARN_EXPERIENCES; + }; +} + +const mockGetEarnToken: jest.MockedFunction< + (token: TokenI) => MockEarnToken | undefined +> = jest.fn(); + jest.mock('../../../Earn/hooks/useEarnTokens', () => ({ __esModule: true, - default: () => ({ getEarnToken: jest.fn() }), + default: () => ({ getEarnToken: mockGetEarnToken }), +})); + +const mockHandleStablecoinLendingRedirect = jest.fn(); +jest.mock('../../../Earn/hooks/useStablecoinLendingRedirect', () => ({ + useStablecoinLendingRedirect: () => mockHandleStablecoinLendingRedirect, })); const mockInitiateConversion = jest.fn(); @@ -162,6 +182,11 @@ const mockSelectIsMusdConversionFlowEnabledFlag = typeof selectIsMusdConversionFlowEnabledFlag >; +const mockSelectStablecoinLendingEnabledFlag = + selectStablecoinLendingEnabledFlag as jest.MockedFunction< + typeof selectStablecoinLendingEnabledFlag + >; + jest.mock('../../util/deriveBalanceFromAssetMarketDetails', () => ({ deriveBalanceFromAssetMarketDetails: jest.fn(() => ({ balanceFiat: '$100.00', @@ -286,6 +311,8 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { isTokenWithCta?: boolean; isGeoEligible?: boolean; isStockToken?: boolean; + isStablecoinLendingEnabled?: boolean; + earnToken?: MockEarnToken; } function prepareMocks({ @@ -295,9 +322,16 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { isTokenWithCta = false, isGeoEligible = true, isStockToken = false, + isStablecoinLendingEnabled = false, + earnToken, }: PrepareMocksOptions = {}) { jest.clearAllMocks(); + mockGetEarnToken.mockReturnValue(earnToken); + mockSelectStablecoinLendingEnabledFlag.mockReturnValue( + isStablecoinLendingEnabled, + ); + // Stock token mocks mockIsStockToken.mockReturnValue(isStockToken); mockIsTokenTradingOpen.mockResolvedValue(true); @@ -340,6 +374,10 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { return isMusdConversionEnabled; } + if (selector === selectStablecoinLendingEnabledFlag) { + return isStablecoinLendingEnabled; + } + const selectorString = selector.toString(); // TokenListItem selectors @@ -960,4 +998,66 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { expect(queryByTestId('stock-badge')).toBeNull(); }); }); + + describe('Stablecoin lending Earn CTA threshold', () => { + const assetKey: FlashListAssetKey = { + address: '0x456', + chainId: '0x1', + isStaked: false, + }; + + it('renders percentage change when stablecoin lending Earn CTA balance is below minimum', () => { + // Arrange + prepareMocks({ + asset: { ...defaultAsset, balance: '0.009' }, + pricePercentChange1d: 1.23, + isStablecoinLendingEnabled: true, + earnToken: { + balanceFiatNumber: 0.009, + experience: { type: EARN_EXPERIENCES.STABLECOIN_LENDING }, + }, + }); + + // Act + const { getByText, queryByText } = renderWithProvider( + , + ); + + // Assert + expect(getByText('+1.23%')).toBeOnTheScreen(); + expect(queryByText(strings('stake.earn'))).toBeNull(); + }); + + it('renders Earn CTA when stablecoin lending is enabled and balance meets minimum', () => { + // Arrange + prepareMocks({ + asset: { ...defaultAsset, balance: '0.01' }, + pricePercentChange1d: 1.23, + isStablecoinLendingEnabled: true, + earnToken: { + balanceFiatNumber: 0.01, + experience: { type: EARN_EXPERIENCES.STABLECOIN_LENDING }, + }, + }); + + // Act + const { getByText, queryByText } = renderWithProvider( + , + ); + + // Assert + expect(getByText(strings('stake.earn'))).toBeOnTheScreen(); + expect(queryByText('+1.23%')).toBeNull(); + }); + }); }); diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx index 2be7af3267e..dad3e78e865 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx @@ -60,6 +60,8 @@ import useEarnTokens from '../../../Earn/hooks/useEarnTokens'; import { EARN_EXPERIENCES } from '../../../Earn/constants/experiences'; import { EVENT_LOCATIONS as EARN_EVENT_LOCATIONS } from '../../../Earn/constants/events/earnEvents'; import { useStablecoinLendingRedirect } from '../../../Earn/hooks/useStablecoinLendingRedirect'; +import BigNumber from 'bignumber.js'; +import { MINIMUM_BALANCE_FOR_EARN_CTA } from '../../../Earn/constants/token'; export const ACCOUNT_TYPE_LABEL_TEST_ID = 'account-type-label'; @@ -294,7 +296,10 @@ export const TokenListItem = React.memo( if ( isStablecoinLendingEnabled && - earnToken?.experience?.type === EARN_EXPERIENCES.STABLECOIN_LENDING + earnToken?.experience?.type === EARN_EXPERIENCES.STABLECOIN_LENDING && + new BigNumber(earnToken?.balanceFiatNumber || '0').gte( + MINIMUM_BALANCE_FOR_EARN_CTA, + ) ) { return { text: `${strings('stake.earn')}`, @@ -326,11 +331,11 @@ export const TokenListItem = React.memo( }, [ hasClaimableBonus, shouldShowConvertToMusdCta, + earnToken, isStablecoinLendingEnabled, - earnToken?.experience?.type, + asset, hasPercentageChange, pricePercentChange1d, - asset, onItemPress, handleConvertToMUSD, handleLendingRedirect, diff --git a/app/components/UI/TransactionElement/utils.test.js b/app/components/UI/TransactionElement/utils.test.js index f67ce307552..4bb42e3d514 100644 --- a/app/components/UI/TransactionElement/utils.test.js +++ b/app/components/UI/TransactionElement/utils.test.js @@ -48,7 +48,6 @@ jest.mock('../../../core/Engine', () => ({ }, }, }, - preventPollingOnNetworkRestart: false, }, }, })); diff --git a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx index bd46e0cb3fc..6aa7c2934fb 100644 --- a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx +++ b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx @@ -116,6 +116,7 @@ const TrendingTokenNetworkBottomSheet: React.FC< shouldNavigateBack={false} ref={sheetRef} onClose={handleSheetClose} + testID="trending-token-network-bottom-sheet" > onTimeOptionPress(TimeOption.TwentyFourHours)} + testID="time-select-24h" > {strings('trending.24_hours')} @@ -173,6 +175,7 @@ const TrendingTokenTimeBottomSheet: React.FC< optionStyles.optionRowSelected, ]} onPress={() => onTimeOptionPress(TimeOption.SixHours)} + testID="time-select-6h" > {strings('trending.6_hours')} @@ -188,6 +191,7 @@ const TrendingTokenTimeBottomSheet: React.FC< optionStyles.optionRowSelected, ]} onPress={() => onTimeOptionPress(TimeOption.OneHour)} + testID="time-select-1h" > {strings('trending.1_hour')} {selectedTime === TimeOption.OneHour && ( @@ -201,6 +205,7 @@ const TrendingTokenTimeBottomSheet: React.FC< optionStyles.optionRowSelected, ]} onPress={() => onTimeOptionPress(TimeOption.FiveMinutes)} + testID="time-select-5m" > {strings('trending.5_minutes')} diff --git a/app/components/Views/Settings/RegionSelector/RegionSelector.test.tsx b/app/components/Views/Settings/RegionSelector/RegionSelector.test.tsx index b259db1a606..b7f11463ea9 100644 --- a/app/components/Views/Settings/RegionSelector/RegionSelector.test.tsx +++ b/app/components/Views/Settings/RegionSelector/RegionSelector.test.tsx @@ -25,10 +25,7 @@ jest.mock('@react-navigation/native', () => { const mockSetUserRegion = jest.fn().mockResolvedValue(undefined); const mockFetchUserRegion = jest.fn().mockResolvedValue(null); -const mockFetchCountries = jest.fn().mockResolvedValue([]); -const mockFetchProviders = jest.fn().mockResolvedValue({ providers: [] }); -const mockFetchTokens = jest.fn().mockResolvedValue(null); -const mockSetPreferredProvider = jest.fn(); +const mockSetSelectedProvider = jest.fn(); const createMockCountry = ( isoCode: string, @@ -42,7 +39,7 @@ const createMockCountry = ( name, flag, states, - supported, + supported: { buy: supported, sell: supported }, recommended, phone: { prefix: '', placeholder: '', template: '' }, currency: '', @@ -55,7 +52,7 @@ const createMockState = ( ): State => ({ stateId, name, - supported, + supported: { buy: supported, sell: supported }, }); const createMockUserRegion = (regionCode: string): UserRegion => { @@ -70,13 +67,13 @@ const createMockUserRegion = (regionCode: string): UserRegion => { name: countryCode, phone: { prefix: '', placeholder: '', template: '' }, currency: '', - supported: true, + supported: { buy: true, sell: true }, }, state: stateCode ? { stateId: stateCode, name: stateCode, - supported: true, + supported: { buy: true, sell: true }, } : null, regionCode: regionCode.toLowerCase(), @@ -101,20 +98,24 @@ const mockUseRampsControllerInitialValues: ReturnType< userRegionError: null, setUserRegion: mockSetUserRegion, fetchUserRegion: mockFetchUserRegion, - preferredProvider: null, - setPreferredProvider: mockSetPreferredProvider, + selectedProvider: null, + setSelectedProvider: mockSetSelectedProvider, providers: [], providersLoading: false, providersError: null, - fetchProviders: mockFetchProviders, tokens: null, + selectedToken: null, + setSelectedToken: jest.fn(), tokensLoading: false, tokensError: null, - fetchTokens: mockFetchTokens, countries: mockRegions, countriesLoading: false, countriesError: null, - fetchCountries: mockFetchCountries, + paymentMethods: [], + selectedPaymentMethod: null, + setSelectedPaymentMethod: jest.fn(), + paymentMethodsLoading: false, + paymentMethodsError: null, }; let mockUseRampsControllerValues = mockUseRampsControllerInitialValues; @@ -155,29 +156,17 @@ describe('RegionSelector', () => { it('renders loading state when regions are loading', () => { mockUseRampsControllerValues = { ...mockUseRampsControllerInitialValues, - countries: null, + countries: [], countriesLoading: true, }; render(RegionSelector); expect(screen.toJSON()).toMatchSnapshot(); }); - it('calls fetchCountries when regions are null', () => { - mockUseRampsControllerValues = { - ...mockUseRampsControllerInitialValues, - countries: null, - countriesLoading: false, - countriesError: null, - }; - render(RegionSelector); - expect(mockFetchCountries).toHaveBeenCalled(); - expect(screen.toJSON()).toMatchSnapshot(); - }); - it('renders error state when countries error occurs', () => { mockUseRampsControllerValues = { ...mockUseRampsControllerInitialValues, - countries: null, + countries: [], countriesLoading: false, countriesError: 'Failed to fetch countries', }; @@ -185,20 +174,6 @@ describe('RegionSelector', () => { expect(screen.toJSON()).toMatchSnapshot(); }); - it('calls fetchCountries when retry button is pressed on error', () => { - mockUseRampsControllerValues = { - ...mockUseRampsControllerInitialValues, - countries: null, - countriesLoading: false, - countriesError: 'Failed to fetch countries', - }; - render(RegionSelector); - jest.clearAllMocks(); - const retryButton = screen.getByTestId('retry-countries-button'); - fireEvent.press(retryButton); - expect(mockFetchCountries).toHaveBeenCalled(); - }); - it('renders with selected user region', () => { mockUseRampsControllerValues = { ...mockUseRampsControllerInitialValues, @@ -377,7 +352,7 @@ describe('RegionSelector', () => { it('renders state without stateId', () => { const stateWithoutId: State = { name: 'State Without ID', - supported: true, + supported: { buy: true, sell: true }, }; const regionsWithStateWithoutId = [ createMockCountry('US', 'United States', '🇺🇸', [stateWithoutId]), @@ -461,13 +436,13 @@ describe('RegionSelector', () => { name: 'United States', phone: { prefix: '', placeholder: '', template: '' }, currency: '', - supported: true, + supported: { buy: true, sell: true }, states: [createMockState('CA', 'California')], }, state: { stateId: 'CA', name: 'California', - supported: true, + supported: { buy: true, sell: true }, }, regionCode: 'us-ca', }; @@ -490,7 +465,7 @@ describe('RegionSelector', () => { const standaloneState: State = { stateId: 'TX', name: 'Texas', - supported: true, + supported: { buy: true, sell: true }, }; const regionsWithStandaloneState = [ createMockCountry('US', 'United States', '🇺🇸', [ @@ -598,7 +573,7 @@ describe('RegionSelector', () => { it('does not call setUserRegion when region selection has empty regionId', async () => { const stateWithoutId: State = { name: 'State Without ID', - supported: true, + supported: { buy: true, sell: true }, }; const regionsWithStateWithoutId = [ createMockCountry('US', 'United States', '🇺🇸', [stateWithoutId]), @@ -730,7 +705,7 @@ describe('RegionSelector', () => { name: 'United States', phone: { prefix: '', placeholder: '', template: '' }, currency: '', - supported: true, + supported: { buy: true, sell: true }, }, state: null, regionCode: 'us', @@ -758,7 +733,7 @@ describe('RegionSelector', () => { const standaloneState: State = { stateId: 'CA', name: 'California', - supported: true, + supported: { buy: true, sell: true }, }; const regionsWithStandaloneState = [ createMockCountry('US', 'United States', '🇺🇸', [standaloneState]), diff --git a/app/components/Views/Settings/RegionSelector/RegionSelector.tsx b/app/components/Views/Settings/RegionSelector/RegionSelector.tsx index e25dffb9807..ceaa512de23 100644 --- a/app/components/Views/Settings/RegionSelector/RegionSelector.tsx +++ b/app/components/Views/Settings/RegionSelector/RegionSelector.tsx @@ -74,6 +74,17 @@ function isGroupedResult(item: ListItem): item is GroupedSearchResult { return 'country' in item && 'matchingStates' in item; } +function isRegionSupported(supported: unknown): boolean { + return ( + supported == null || + supported === true || + (typeof supported === 'object' && + supported !== null && + (Boolean((supported as { buy?: boolean }).buy) || + Boolean((supported as { sell?: boolean }).sell))) + ); +} + interface HeaderBackButtonProps { onPress: () => void; testID?: string; @@ -102,12 +113,11 @@ function RegionSelector() { countries, countriesLoading, countriesError, - fetchCountries, } = useRampsController(); const [searchString, setSearchString] = useState(''); const [activeView, setActiveView] = useState(RegionViewType.COUNTRY); - const [currentData, setCurrentData] = useState(countries || []); + const [currentData, setCurrentData] = useState(countries); const [regionInTransit, setRegionInTransit] = useState(null); const { styles } = useStyles(styleSheet, {}); @@ -126,13 +136,7 @@ function RegionSelector() { }, [colors, navigation, activeView, regionInTransit]); useEffect(() => { - if (!countries && !countriesLoading && !countriesError) { - fetchCountries(); - } - }, [countries, countriesLoading, countriesError, fetchCountries]); - - useEffect(() => { - if (countries && activeView === RegionViewType.COUNTRY) { + if (countries.length > 0 && activeView === RegionViewType.COUNTRY) { setCurrentData(countries); } }, [countries, activeView]); @@ -341,7 +345,7 @@ function RegionSelector() { ({ item }: { item: ListItem }) => { if (isGroupedResult(item)) { const countryIsSelected = isRegionSelected(item.country); - const isSupported = item.country.supported !== false; + const isSupported = isRegionSupported(item.country.supported); const showStateName = userRegion?.state && activeView === RegionViewType.COUNTRY && @@ -356,7 +360,7 @@ function RegionSelector() { onPress={() => handleOnRegionPressCallback(item.country)} accessibilityRole="button" accessible - disabled={!isSupported} + isDisabled={!isSupported} > @@ -402,7 +406,7 @@ function RegionSelector() { {item.matchingStates.map((state) => { const stateIsSelected = isRegionSelected(state, item.country); - const isStateSupported = state.supported !== false; + const isStateSupported = isRegionSupported(state.supported); return ( @@ -445,7 +449,7 @@ function RegionSelector() { ); if (isCountry(region)) { - const isSupported = region.supported !== false; + const isSupported = isRegionSupported(region.supported); const showStateName = userRegion?.state && activeView === RegionViewType.COUNTRY && @@ -459,7 +463,7 @@ function RegionSelector() { onPress={() => handleOnRegionPressCallback(region)} accessibilityRole="button" accessible - disabled={!isSupported} + isDisabled={!isSupported} > @@ -501,14 +505,14 @@ function RegionSelector() { ); } - const isStateSupported = region.supported !== false; + const isStateSupported = isRegionSupported(region.supported); return ( handleOnRegionPressCallback(region)} accessibilityRole="button" accessible - disabled={!isStateSupported} + isDisabled={!isStateSupported} > @@ -542,7 +546,7 @@ function RegionSelector() { ); const renderEmptyList = useCallback(() => { - if (countriesLoading && !countries) { + if (countriesLoading && countries.length === 0) { return ( @@ -550,7 +554,7 @@ function RegionSelector() { ); } - if (countriesError && !countries) { + if (countriesError && countries.length === 0) { return ( @@ -559,13 +563,6 @@ function RegionSelector() { {countriesError} - fetchCountries()} - style={styles.retryButton} - testID="retry-countries-button" - /> ); } @@ -590,9 +587,7 @@ function RegionSelector() { searchString, styles.emptyList, styles.errorText, - styles.retryButton, colors.primary.default, - fetchCountries, ]); const handleSearchTextChange = useCallback( 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 bc4b284a641..060e57049a8 100644 --- a/app/components/Views/Settings/RegionSelector/__snapshots__/RegionSelector.test.tsx.snap +++ b/app/components/Views/Settings/RegionSelector/__snapshots__/RegionSelector.test.tsx.snap @@ -1,506 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`RegionSelector calls fetchCountries when regions are null 1`] = ` - - - - - - - - - - - - - SettingsRegionSelector - - - - - - - - - - - - - - - - - - - - Payment methods and available tokens may vary based on your region and our providers. - - - - - - - - - - - - - - - - - - - - - - - - -`; - exports[`RegionSelector clears search and scrolls to top when clear button is pressed 1`] = ` Failed to fetch countries - - - @@ -24346,7 +24198,10 @@ exports[`RegionSelector renders recommended countries 1`] = ` }, "recommended": true, "states": undefined, - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, { "currency": "", @@ -24360,7 +24215,10 @@ exports[`RegionSelector renders recommended countries 1`] = ` }, "recommended": false, "states": undefined, - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, ] } @@ -25060,14 +24918,17 @@ exports[`RegionSelector renders state with supported set to false 1`] = ` { "name": "Texas", "stateId": "TX", - "supported": false, + "supported": { + "buy": false, + "sell": false, + }, }, ] } extraData={null} getItem={[Function]} getItemCount={[Function]} - handlerTag={77} + handlerTag={73} handlerType="NativeViewGestureHandler" keyExtractor={[Function]} keyboardDismissMode="none" @@ -25108,7 +24969,7 @@ exports[`RegionSelector renders state with supported set to false 1`] = ` { "backgroundColor": "#ffffff", "borderRadius": 4, - "opacity": 1, + "opacity": 0.5, "position": "relative", } } @@ -25661,10 +25522,16 @@ exports[`RegionSelector renders state without stateId 1`] = ` "states": [ { "name": "State Without ID", - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, ], - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, ] } @@ -26292,19 +26159,25 @@ exports[`RegionSelector renders states view 1`] = ` { "name": "California", "stateId": "CA", - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, { "name": "New York", "stateId": "NY", - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, ] } extraData={null} getItem={[Function]} getItemCount={[Function]} - handlerTag={23} + handlerTag={19} handlerType="NativeViewGestureHandler" keyExtractor={[Function]} keyboardDismissMode="none" @@ -26976,7 +26849,10 @@ exports[`RegionSelector renders unsupported country 1`] = ` }, "recommended": true, "states": undefined, - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, { "currency": "", @@ -26993,15 +26869,24 @@ exports[`RegionSelector renders unsupported country 1`] = ` { "name": "California", "stateId": "CA", - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, { "name": "New York", "stateId": "NY", - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, ], - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, { "currency": "", @@ -27018,10 +26903,16 @@ exports[`RegionSelector renders unsupported country 1`] = ` { "name": "Ontario", "stateId": "ON", - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, ], - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, { "currency": "", @@ -27035,7 +26926,10 @@ exports[`RegionSelector renders unsupported country 1`] = ` }, "recommended": false, "states": undefined, - "supported": false, + "supported": { + "buy": false, + "sell": false, + }, }, ] } @@ -27454,7 +27348,7 @@ exports[`RegionSelector renders unsupported country 1`] = ` { "backgroundColor": "#ffffff", "borderRadius": 4, - "opacity": 1, + "opacity": 0.5, "position": "relative", } } @@ -28003,19 +27897,25 @@ exports[`RegionSelector renders unsupported state 1`] = ` { "name": "California", "stateId": "CA", - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, { "name": "Texas", "stateId": "TX", - "supported": false, + "supported": { + "buy": false, + "sell": false, + }, }, ] } extraData={null} getItem={[Function]} getItemCount={[Function]} - handlerTag={49} + handlerTag={45} handlerType="NativeViewGestureHandler" keyExtractor={[Function]} keyboardDismissMode="none" @@ -28136,7 +28036,7 @@ exports[`RegionSelector renders unsupported state 1`] = ` { "backgroundColor": "#ffffff", "borderRadius": 4, - "opacity": 1, + "opacity": 0.5, "position": "relative", } } @@ -28687,7 +28587,10 @@ exports[`RegionSelector renders when country has states and user region state is }, "recommended": true, "states": undefined, - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, { "currency": "", @@ -28704,15 +28607,24 @@ exports[`RegionSelector renders when country has states and user region state is { "name": "California", "stateId": "CA", - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, { "name": "New York", "stateId": "NY", - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, ], - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, { "currency": "", @@ -28729,10 +28641,16 @@ exports[`RegionSelector renders when country has states and user region state is { "name": "Ontario", "stateId": "ON", - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, ], - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, { "currency": "", @@ -28746,7 +28664,10 @@ exports[`RegionSelector renders when country has states and user region state is }, "recommended": false, "states": undefined, - "supported": false, + "supported": { + "buy": false, + "sell": false, + }, }, ] } @@ -28762,13 +28683,19 @@ exports[`RegionSelector renders when country has states and user region state is "prefix": "", "template": "", }, - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, "regionCode": "us-ca", "state": { "name": "CA", "stateId": "CA", - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, } } @@ -29215,7 +29142,7 @@ exports[`RegionSelector renders when country has states and user region state is { "backgroundColor": "#ffffff", "borderRadius": 4, - "opacity": 1, + "opacity": 0.5, "position": "relative", } } @@ -30290,7 +30217,10 @@ exports[`RegionSelector renders with selected user region 1`] = ` }, "recommended": true, "states": undefined, - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, { "currency": "", @@ -30307,15 +30237,24 @@ exports[`RegionSelector renders with selected user region 1`] = ` { "name": "California", "stateId": "CA", - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, { "name": "New York", "stateId": "NY", - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, ], - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, { "currency": "", @@ -30332,10 +30271,16 @@ exports[`RegionSelector renders with selected user region 1`] = ` { "name": "Ontario", "stateId": "ON", - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, ], - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, { "currency": "", @@ -30349,7 +30294,10 @@ exports[`RegionSelector renders with selected user region 1`] = ` }, "recommended": false, "states": undefined, - "supported": false, + "supported": { + "buy": false, + "sell": false, + }, }, ] } @@ -30365,13 +30313,19 @@ exports[`RegionSelector renders with selected user region 1`] = ` "prefix": "", "template": "", }, - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, "regionCode": "us-ca", "state": { "name": "CA", "stateId": "CA", - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, } } @@ -30818,7 +30772,7 @@ exports[`RegionSelector renders with selected user region 1`] = ` { "backgroundColor": "#ffffff", "borderRadius": 4, - "opacity": 1, + "opacity": 0.5, "position": "relative", } } @@ -31367,19 +31321,25 @@ exports[`RegionSelector resets search when navigating to state view 1`] = ` { "name": "California", "stateId": "CA", - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, { "name": "New York", "stateId": "NY", - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, ] } extraData={null} getItem={[Function]} getItemCount={[Function]} - handlerTag={73} + handlerTag={69} handlerType="NativeViewGestureHandler" keyExtractor={[Function]} keyboardDismissMode="none" @@ -32083,7 +32043,7 @@ exports[`RegionSelector scrolls to top when search text changes 1`] = ` extraData={null} getItem={[Function]} getItemCount={[Function]} - handlerTag={55} + handlerTag={51} handlerType="NativeViewGestureHandler" keyExtractor={[Function]} keyboardDismissMode="none" @@ -32595,19 +32555,25 @@ exports[`RegionSelector sets up back button in state view 1`] = ` { "name": "California", "stateId": "CA", - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, { "name": "New York", "stateId": "NY", - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, ] } extraData={null} getItem={[Function]} getItemCount={[Function]} - handlerTag={57} + handlerTag={53} handlerType="NativeViewGestureHandler" keyExtractor={[Function]} keyboardDismissMode="none" @@ -33279,7 +33245,10 @@ exports[`RegionSelector shows state name in country view when user has selected }, "recommended": true, "states": undefined, - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, { "currency": "", @@ -33296,15 +33265,24 @@ exports[`RegionSelector shows state name in country view when user has selected { "name": "California", "stateId": "CA", - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, { "name": "New York", "stateId": "NY", - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, ], - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, { "currency": "", @@ -33321,10 +33299,16 @@ exports[`RegionSelector shows state name in country view when user has selected { "name": "Ontario", "stateId": "ON", - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, ], - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, { "currency": "", @@ -33338,7 +33322,10 @@ exports[`RegionSelector shows state name in country view when user has selected }, "recommended": false, "states": undefined, - "supported": false, + "supported": { + "buy": false, + "sell": false, + }, }, ] } @@ -33358,16 +33345,25 @@ exports[`RegionSelector shows state name in country view when user has selected { "name": "California", "stateId": "CA", - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, ], - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, "regionCode": "us-ca", "state": { "name": "California", "stateId": "CA", - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, } } @@ -33814,7 +33810,7 @@ exports[`RegionSelector shows state name in country view when user has selected { "backgroundColor": "#ffffff", "borderRadius": 4, - "opacity": 1, + "opacity": 0.5, "position": "relative", } } @@ -34388,7 +34384,10 @@ exports[`RegionSelector sorts regions with recommended first when no search 1`] }, "recommended": true, "states": undefined, - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, { "currency": "", @@ -34402,7 +34401,10 @@ exports[`RegionSelector sorts regions with recommended first when no search 1`] }, "recommended": false, "states": undefined, - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, { "currency": "", @@ -34416,7 +34418,10 @@ exports[`RegionSelector sorts regions with recommended first when no search 1`] }, "recommended": false, "states": undefined, - "supported": true, + "supported": { + "buy": true, + "sell": true, + }, }, ] } diff --git a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx b/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx index 087d4289c64..4f72a2278c2 100644 --- a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx +++ b/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx @@ -1,9 +1,35 @@ import React from 'react'; -import { fireEvent, waitFor, act } from '@testing-library/react-native'; +import { render, userEvent, fireEvent } from '@testing-library/react-native'; +import { Metrics, SafeAreaProvider } from 'react-native-safe-area-context'; import renderWithProvider from '../../../../util/test/renderWithProvider'; -import TrendingTokensFullView from './TrendingTokensFullView'; +import TrendingTokensFullView, { + TrendingTokensData, + TrendingTokensDataProps, +} from './TrendingTokensFullView'; import type { TrendingAsset } from '@metamask/assets-controllers'; import { useTrendingSearch } from '../../../UI/Trending/hooks/useTrendingSearch/useTrendingSearch'; +import { + TimeOption, + PriceChangeOption, +} from '../../../UI/Trending/components/TrendingTokensBottomSheet'; +import { TrendingFilterContext } from '../../../UI/Trending/components/TrendingTokensList/TrendingTokensList'; + +import { useTrendingRequest } from '../../../UI/Trending/hooks/useTrendingRequest/useTrendingRequest'; +import type TrendingTokensList from '../../../UI/Trending/components/TrendingTokensList'; +import mockState from '../../../../util/test/initial-root-state'; + +const TEST_IDS = { + skeleton: 'trending-tokens-skeleton', + emptySearchResult: 'empty-search-result-state', + emptyErrorState: 'empty-error-trending-state', + tokensList: 'trending-tokens-list', + retryButton: 'empty-error-trending-state--retry-button', +} as const; + +const initialMetrics: Metrics = { + frame: { x: 0, y: 0, width: 320, height: 640 }, + insets: { top: 0, left: 0, right: 0, bottom: 0 }, +}; const mockNavigate = jest.fn(); const mockGoBack = jest.fn(); @@ -16,193 +42,28 @@ jest.mock('@react-navigation/native', () => ({ createNavigatorFactory: () => ({}), })); -const mockFetchTrendingTokens = jest.fn(); -const mockUseTrendingRequest = jest.fn().mockReturnValue({ - results: [], - isLoading: false, - error: null, - fetch: mockFetchTrendingTokens, -}); -jest.mock( - '../../../UI/Trending/hooks/useTrendingRequest/useTrendingRequest', - () => ({ - useTrendingRequest: (options: unknown) => mockUseTrendingRequest(options), - }), -); +jest.mock('../../../UI/Trending/hooks/useTrendingRequest/useTrendingRequest'); +const mockUseTrendingRequest = jest.mocked(useTrendingRequest); jest.mock('../../../UI/Trending/hooks/useTrendingSearch/useTrendingSearch'); const mockUseTrendingSearch = jest.mocked(useTrendingSearch); -// Mock sections.config to avoid complex Perps dependencies -jest.mock('../../TrendingView/sections.config', () => ({ - SECTIONS_CONFIG: { - tokens: { - getSearchableText: (item: { name?: string; symbol?: string }) => - `${item.name || ''} ${item.symbol || ''}`.toLowerCase(), - }, - }, -})); - jest.mock( '../../../UI/Trending/components/TrendingTokensList/TrendingTokensList', - () => { - const { View, Text } = jest.requireActual('react-native'); - return ({ - trendingTokens, - onTokenPress, - ...rest - }: { - trendingTokens: TrendingAsset[]; - onTokenPress: (token: TrendingAsset) => void; - }) => ( - + (): typeof TrendingTokensList => { + const { View, Text, ScrollView } = jest.requireActual('react-native'); + return ({ trendingTokens, refreshControl }) => ( + {trendingTokens.map((token, index) => ( - onTokenPress(token)} - > + {token.name} ))} - - ); - }, -); - -jest.mock( - '../../../UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton', - () => { - const { View } = jest.requireActual('react-native'); - return ({ count }: { count: number }) => ( - + ); }, ); -jest.mock('../../../../util/navigation/navUtils', () => ({ - createNavigationDetails: jest.fn( - (stackId, screenName) => (params?: unknown) => [ - stackId, - { screen: screenName, params }, - ], - ), -})); - -jest.mock( - '../../TrendingView/components/EmptyErrorState/EmptyErrorTrendingState', - () => { - const { View, Text } = jest.requireActual('react-native'); - return jest.fn(({ onRetry }: { onRetry?: () => void }) => ( - - Trending tokens is not available - {onRetry && } - - )); - }, -); - -jest.mock( - '../../TrendingView/components/EmptyErrorState/EmptySearchResultState', - () => { - const { View, Text } = jest.requireActual('react-native'); - return jest.fn(() => ( - - No tokens found - - )); - }, -); - -jest.mock('../../../UI/Trending/components/TrendingTokensBottomSheet', () => { - const { View } = jest.requireActual('react-native'); - return { - TrendingTokenTimeBottomSheet: ({ - isVisible, - onClose, - onTimeSelect, - }: { - isVisible: boolean; - onClose: () => void; - onTimeSelect?: (sortBy: string, timeOption: string) => void; - }) => { - if (!isVisible) return null; - return ( - - onTimeSelect?.('h24_trending', '24h')} - /> - onTimeSelect?.('h6_trending', '6h')} - /> - - - ); - }, - TrendingTokenNetworkBottomSheet: ({ - isVisible, - onClose, - onNetworkSelect, - }: { - isVisible: boolean; - onClose: () => void; - onNetworkSelect?: (chainIds: string[] | null) => void; - }) => { - if (!isVisible) return null; - return ( - - onNetworkSelect?.(null)} - /> - onNetworkSelect?.(['eip155:1'])} - /> - - - ); - }, - TrendingTokenPriceChangeBottomSheet: ({ - isVisible, - onClose, - onPriceChangeSelect, - }: { - isVisible: boolean; - onClose: () => void; - onPriceChangeSelect?: (option: string, sortDirection: string) => void; - }) => { - if (!isVisible) return null; - return ( - - onPriceChangeSelect?.('volume', 'ascending')} - /> - - - ); - }, - TimeOption: { - TwentyFourHours: '24h', - SixHours: '6h', - OneHour: '1h', - FiveMinutes: '5m', - }, - PriceChangeOption: { - PriceChange: 'price_change', - Volume: 'volume', - MarketCap: 'market_cap', - }, - SortDirection: { - Ascending: 'ascending', - Descending: 'descending', - }, - }; -}); - const createMockToken = ( overrides: Partial = {}, ): TrendingAsset => ({ @@ -219,173 +80,221 @@ const createMockToken = ( ...overrides, }); -describe('TrendingTokensFullView', () => { - const mockState = { - engine: { - backgroundState: { - NetworkController: { - networkConfigurations: {}, - networkConfigurationsByChainId: {}, - }, - MultichainNetworkController: { - selectedMultichainNetworkChainId: undefined, - multichainNetworkConfigurationsByChainId: {}, - }, - }, - }, +const arrangeMocks = () => { + const mockRefetch = jest.fn(); + + const setTrendingSearchMock = (options: { + data?: TrendingAsset[]; + isLoading?: boolean; + }) => { + mockUseTrendingSearch.mockReturnValue({ + data: options.data ?? [], + isLoading: options.isLoading ?? false, + refetch: mockRefetch, + }); }; - beforeEach(() => { - jest.clearAllMocks(); + const setTrendingRequestMock = (options: { + results?: TrendingAsset[]; + isLoading?: boolean; + error?: Error | null; + }) => { mockUseTrendingRequest.mockReturnValue({ - results: [], - isLoading: false, - error: null, + results: options.results ?? [], + isLoading: options.isLoading ?? false, + error: options.error ?? null, fetch: jest.fn(), }); - mockUseTrendingSearch.mockReturnValue({ - data: [], - isLoading: false, - refetch: jest.fn(), - }); - }); - - it('renders header with title and buttons', () => { - const { getByText, getByTestId } = renderWithProvider( - , - { state: mockState }, - false, // Exclude NavigationContainer since we're mocking navigation - ); + }; - expect(getByText('Trending tokens')).toBeOnTheScreen(); - expect(getByTestId('trending-tokens-header-back-button')).toBeOnTheScreen(); - }); + return { + mockRefetch, + mockGoBack, + mockNavigate, + setTrendingSearchMock, + setTrendingRequestMock, + }; +}; + +describe('TrendingTokensData', () => { + const mockTokens = [ + createMockToken({ name: 'Token 1', assetId: 'eip155:1/erc20:0x123' }), + createMockToken({ name: 'Token 2', assetId: 'eip155:1/erc20:0x456' }), + ]; + + const mockFilterContext: TrendingFilterContext = { + timeFilter: TimeOption.TwentyFourHours, + sortOption: PriceChangeOption.PriceChange, + networkFilter: 'all', + isSearchResult: false, + }; - it('renders control buttons', () => { - const { getByTestId, getByText } = renderWithProvider( - , - { state: mockState }, - false, - ); + const mockTheme = { + colors: { + primary: { default: '#037DD6' }, + icon: { default: '#6A737D' }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; - expect(getByTestId('price-change-button')).toBeOnTheScreen(); - expect(getByTestId('all-networks-button')).toBeOnTheScreen(); - expect(getByTestId('24h-button')).toBeOnTheScreen(); - expect(getByText('Price change')).toBeOnTheScreen(); - expect(getByText('All networks')).toBeOnTheScreen(); - expect(getByText('24h')).toBeOnTheScreen(); + beforeEach(() => { + jest.clearAllMocks(); }); - it('navigates back when back button is pressed', () => { - const { getByTestId } = renderWithProvider( - , - { state: mockState }, - false, - ); + // Test table for different states + const stateTests: { + testName: string; + overrideProps?: Partial; + elemsRendered: string[]; + actAssert?: ( + testUtils: ReturnType, + props: TrendingTokensDataProps, + ) => Promise; + }[] = [ + { + testName: 'loading skeleton when loading is true', + overrideProps: { + isLoading: true, + }, + elemsRendered: [TEST_IDS.skeleton], + }, + { + testName: 'no loading skeleton when loading is false', + overrideProps: { + isLoading: false, + }, + elemsRendered: [TEST_IDS.tokensList], + }, + { + testName: + 'empty search results when typing a search query that returns no results', + overrideProps: { + search: { searchResults: [], searchQuery: 'nonexistent' }, + }, + elemsRendered: [TEST_IDS.emptySearchResult], + }, + { + testName: 'empty error state when there is an error on main token view', + overrideProps: { + trendingTokens: [], + search: { searchResults: [], searchQuery: '' }, + }, + elemsRendered: [TEST_IDS.emptyErrorState], + actAssert: async (testUtils, props) => { + const { getByTestId } = testUtils; + const retryButton = getByTestId(TEST_IDS.retryButton); + await userEvent.press(retryButton); - const backButton = getByTestId('trending-tokens-header-back-button'); - fireEvent.press(backButton); + expect(props.handleRefresh).toHaveBeenCalledTimes(1); + }, + }, + { + testName: 'tokens list when there are tokens', + elemsRendered: [TEST_IDS.tokensList], + actAssert: async (testUtils) => { + const { getByText } = testUtils; + expect(getByText('Token 1')).toBeOnTheScreen(); + expect(getByText('Token 2')).toBeOnTheScreen(); + }, + }, + ]; - expect(mockGoBack).toHaveBeenCalled(); - }); + it.each(stateTests)( + 'renders correct state - $testName', + async ({ overrideProps, elemsRendered, actAssert }) => { + const baseProps: TrendingTokensDataProps = { + isLoading: false, + refreshing: false, + trendingTokens: mockTokens, + handleRefresh: jest.fn(), + selectedTimeOption: TimeOption.TwentyFourHours, + filterContext: mockFilterContext, + theme: mockTheme, + search: { + searchResults: mockTokens, + searchQuery: '', + }, + }; - it('opens time bottom sheet when 24h button is pressed', () => { - const { getByTestId } = renderWithProvider( - , - { state: mockState }, - false, - ); + const props: TrendingTokensDataProps = { ...baseProps, ...overrideProps }; - const button24h = getByTestId('24h-button'); - fireEvent.press(button24h); + const testUtils = render(); - expect(getByTestId('trending-token-time-bottom-sheet')).toBeOnTheScreen(); - }); + elemsRendered.forEach((id) => { + expect(testUtils.getByTestId(id)).toBeOnTheScreen(); + }); - it('opens network bottom sheet when all networks button is pressed', () => { - const { getByTestId } = renderWithProvider( - , + await actAssert?.(testUtils, props); + }, + ); +}); + +describe('TrendingTokensFullView', () => { + const renderTrendingFullView = () => + renderWithProvider( + + + , { state: mockState }, false, ); - const allNetworksButton = getByTestId('all-networks-button'); - fireEvent.press(allNetworksButton); + beforeEach(() => { + jest.clearAllMocks(); + const mocks = arrangeMocks(); + mocks.setTrendingRequestMock({ results: [] }); + mocks.setTrendingSearchMock({ data: [] }); + }); + + it('renders header with title and buttons', () => { + const { getByText, getByTestId } = renderTrendingFullView(); - expect( - getByTestId('trending-token-network-bottom-sheet'), - ).toBeOnTheScreen(); + expect(getByText('Trending tokens')).toBeOnTheScreen(); + expect(getByTestId('trending-tokens-header-back-button')).toBeOnTheScreen(); }); - it('opens price change bottom sheet when price change button is pressed', () => { - const { getByTestId } = renderWithProvider( - , - { state: mockState }, - false, - ); + it('navigates back when back button is pressed', async () => { + const mocks = arrangeMocks(); + const { getByTestId } = renderTrendingFullView(); - const priceChangeButton = getByTestId('price-change-button'); - fireEvent.press(priceChangeButton); + const backButton = getByTestId('trending-tokens-header-back-button'); + await userEvent.press(backButton); - expect( - getByTestId('trending-token-price-change-bottom-sheet'), - ).toBeTruthy(); + expect(mocks.mockGoBack).toHaveBeenCalled(); }); it('displays skeleton loader when loading', () => { - mockUseTrendingSearch.mockReturnValue({ - data: [], - isLoading: true, - refetch: jest.fn(), - }); + const mocks = arrangeMocks(); + mocks.setTrendingSearchMock({ data: [], isLoading: true }); - const { queryAllByTestId } = renderWithProvider( - , - { state: mockState }, - false, - ); + const { queryByTestId } = renderTrendingFullView(); - const skeletons = queryAllByTestId('trending-tokens-skeleton'); - expect(skeletons.length).toBeGreaterThan(0); - expect(skeletons[0]).toBeOnTheScreen(); + const skeletonsContainer = queryByTestId(TEST_IDS.skeleton); + expect(skeletonsContainer).toBeOnTheScreen(); }); it('displays empty error state when results are empty without search query', () => { - mockUseTrendingSearch.mockReturnValue({ - data: [], - isLoading: false, - refetch: jest.fn(), - }); + const mocks = arrangeMocks(); + mocks.setTrendingSearchMock({ data: [] }); - const { getByText } = renderWithProvider( - , - { state: mockState }, - false, - ); + const { getByText } = renderTrendingFullView(); expect(getByText('Trending tokens is not available')).toBeOnTheScreen(); }); - it('displays empty search result state when search returns no results', () => { - mockUseTrendingSearch.mockReturnValue({ - data: [], - isLoading: false, - refetch: jest.fn(), - }); + it('displays empty search result state when search returns no results', async () => { + const mocks = arrangeMocks(); + mocks.setTrendingSearchMock({ data: [] }); - const { getByText, getByTestId } = renderWithProvider( - , - { state: mockState }, - false, - ); + const { getByText, getByTestId } = renderTrendingFullView(); // Open search const searchToggle = getByTestId('trending-tokens-header-search-toggle'); - fireEvent.press(searchToggle); + await userEvent.press(searchToggle); // Type search query const searchInput = getByTestId('trending-tokens-header-search-bar'); - fireEvent.changeText(searchInput, 'nonexistenttoken'); + await userEvent.type(searchInput, 'nonexistenttoken'); expect(getByTestId('empty-search-result-state')).toBeOnTheScreen(); expect(getByText('No tokens found')).toBeOnTheScreen(); @@ -397,24 +306,11 @@ describe('TrendingTokensFullView', () => { createMockToken({ name: 'Token 2', assetId: 'eip155:1/erc20:0x456' }), ]; - mockUseTrendingRequest.mockReturnValue({ - results: mockTokens, - isLoading: false, - error: null, - fetch: jest.fn(), - }); - - mockUseTrendingSearch.mockReturnValue({ - data: mockTokens, - isLoading: false, - refetch: jest.fn(), - }); + const mocks = arrangeMocks(); + mocks.setTrendingRequestMock({ results: mockTokens }); + mocks.setTrendingSearchMock({ data: mockTokens }); - const { getByTestId, getByText } = renderWithProvider( - , - { state: mockState }, - false, - ); + const { getByTestId, getByText } = renderTrendingFullView(); expect(getByTestId('trending-tokens-list')).toBeOnTheScreen(); expect(getByText('Token 1')).toBeOnTheScreen(); @@ -422,7 +318,7 @@ describe('TrendingTokensFullView', () => { }); it('calls useTrendingSearch with correct initial parameters', () => { - renderWithProvider(, { state: mockState }, false); + renderTrendingFullView(); expect(mockUseTrendingSearch).toHaveBeenCalledWith({ sortBy: undefined, @@ -431,132 +327,92 @@ describe('TrendingTokensFullView', () => { }); }); - it('updates sortBy when time option is selected', async () => { - const { getByTestId } = renderWithProvider( - , - { state: mockState }, - false, - ); - - const button24h = getByTestId('24h-button'); - fireEvent.press(button24h); - - const timeSelect6h = getByTestId('time-select-6h'); - await act(async () => { - fireEvent(timeSelect6h, 'touchEnd'); - }); - - await waitFor(() => { - expect(mockUseTrendingSearch).toHaveBeenLastCalledWith({ - sortBy: 'h6_trending', - chainIds: null, - searchQuery: undefined, - }); - }); - }); - - it('updates chainIds when network is selected', async () => { - const { getByTestId } = renderWithProvider( - , - { state: mockState }, - false, - ); - - const allNetworksButton = getByTestId('all-networks-button'); - fireEvent.press(allNetworksButton); - - const networkSelect = getByTestId('network-select-eip155:1'); - await act(async () => { - fireEvent(networkSelect, 'touchEnd'); - }); - - await waitFor(() => { - expect(mockUseTrendingSearch).toHaveBeenLastCalledWith({ - sortBy: undefined, - chainIds: ['eip155:1'], - searchQuery: undefined, - }); - }); - }); - - it('updates price change filter when option is selected', async () => { + it('calls refetch when pull-to-refresh is triggered', () => { const mockTokens = [ createMockToken({ name: 'Token 1', assetId: 'eip155:1/erc20:0x123' }), - createMockToken({ name: 'Token 2', assetId: 'eip155:1/erc20:0x456' }), ]; - mockUseTrendingRequest.mockReturnValue({ - results: mockTokens, - isLoading: false, - error: null, - fetch: jest.fn(), - }); + const mocks = arrangeMocks(); + mocks.setTrendingRequestMock({ results: mockTokens }); + mocks.setTrendingSearchMock({ data: mockTokens }); - mockUseTrendingSearch.mockReturnValue({ - data: mockTokens, - isLoading: false, - refetch: jest.fn(), - }); + const { getByTestId, UNSAFE_getByType } = renderTrendingFullView(); - const { getByTestId, getByText } = renderWithProvider( - , - { state: mockState }, - false, - ); - - // Open price change bottom sheet - const priceChangeButton = getByTestId('price-change-button'); - fireEvent.press(priceChangeButton); - - // Select Volume option (which maps to PriceChangeOption.Volume and ascending sort) - const volumeOption = getByTestId('price-change-select-volume'); - await act(async () => { - fireEvent(volumeOption, 'touchEnd'); - }); + expect(getByTestId('trending-tokens-list')).toBeOnTheScreen(); + const { RefreshControl } = jest.requireActual('react-native'); + const refreshControl = UNSAFE_getByType(RefreshControl); + fireEvent(refreshControl, 'refresh'); - // Price change button label should update to "Volume" - expect(getByText('Volume')).toBeOnTheScreen(); + expect(mocks.mockRefetch).toHaveBeenCalledTimes(1); }); - it('triggers section refetch on pull-to-refresh', async () => { - const mockTokens = [ - createMockToken({ - assetId: 'eip155:1/erc20:0xabc', - name: 'Token 1', - symbol: 'TKN1', - }), - ]; - - mockUseTrendingRequest.mockReturnValue({ - results: mockTokens, - isLoading: false, - error: null, - fetch: mockFetchTrendingTokens, - }); - - mockUseTrendingSearch.mockReturnValue({ - data: mockTokens, - isLoading: false, - refetch: mockFetchTrendingTokens, - }); - - const { getByTestId } = renderWithProvider( - , - { state: mockState }, - false, - ); + const bottomSheetTests = [ + { + testName: 'time', + sheetButtonTestId: '24h-button', + sheetTestId: 'trending-token-time-bottom-sheet', + actAssertSelectOption: async ( + testUtils: ReturnType, + ) => { + const { getByTestId } = testUtils; + const timeSelect6h = getByTestId('time-select-6h'); + await userEvent.press(timeSelect6h); + + expect(mockUseTrendingSearch).toHaveBeenLastCalledWith({ + sortBy: 'h6_trending', + chainIds: null, + searchQuery: undefined, + }); + }, + }, + { + testName: 'network', + sheetButtonTestId: 'all-networks-button', + sheetTestId: 'trending-token-network-bottom-sheet', + actAssertSelectOption: async ( + testUtils: ReturnType, + ) => { + const { getByTestId } = testUtils; + const networkSelect = getByTestId('network-select-eip155:1'); + await userEvent.press(networkSelect); + + expect(mockUseTrendingSearch).toHaveBeenLastCalledWith({ + sortBy: undefined, + chainIds: ['eip155:1'], + searchQuery: undefined, + }); + }, + }, + { + testName: 'price change', + sheetButtonTestId: 'price-change-button', + sheetTestId: 'trending-token-price-change-bottom-sheet', + actAssertSelectOption: async ( + testUtils: ReturnType, + ) => { + const { getByTestId, getByText } = testUtils; + const priceChangeSelect = getByTestId('price-change-select-volume'); + await userEvent.press(priceChangeSelect); + await userEvent.press(getByText('Apply')); + + expect(getByTestId('price-change-button')).toHaveTextContent('Volume'); + }, + }, + ] as const; - const list = getByTestId('trending-tokens-list'); + it.each(bottomSheetTests)( + 'opens $testName bottom sheet when button is pressed', + async ({ sheetButtonTestId, sheetTestId, actAssertSelectOption }) => { + const testUtils = renderTrendingFullView(); - // Simulate pull-to-refresh via RefreshControl's onRefresh - const refreshControl = list.props.refreshControl; + const triggerButton = testUtils.getByTestId(sheetButtonTestId); + await userEvent.press(triggerButton); - await act(async () => { - await refreshControl.props.onRefresh(); - }); + expect(testUtils.getByTestId(sheetTestId)).toBeOnTheScreen(); - expect(mockFetchTrendingTokens).toHaveBeenCalledTimes(1); - }); + await actAssertSelectOption(testUtils); + }, + ); describe('trendingTokens sorting logic', () => { it('returns search results in relevance order when search query is present', async () => { @@ -575,17 +431,10 @@ describe('TrendingTokensFullView', () => { }), ]; - mockUseTrendingSearch.mockReturnValue({ - data: mockTokens, - isLoading: false, - refetch: jest.fn(), - }); + const mocks = arrangeMocks(); + mocks.setTrendingSearchMock({ data: mockTokens }); - const { getByTestId, getByText } = renderWithProvider( - , - { state: mockState }, - false, - ); + const { getByTestId, getByText } = renderTrendingFullView(); // Open search and type a query const searchToggle = getByTestId('trending-tokens-header-search-toggle'); @@ -619,17 +468,10 @@ describe('TrendingTokensFullView', () => { }), ]; - mockUseTrendingSearch.mockReturnValue({ - data: mockTokens, - isLoading: false, - refetch: jest.fn(), - }); + const mocks = arrangeMocks(); + mocks.setTrendingSearchMock({ data: mockTokens }); - const { getByTestId, getByText } = renderWithProvider( - , - { state: mockState }, - false, - ); + const { getByTestId, getByText } = renderTrendingFullView(); // No price change option selected by default, tokens should be in original order expect(getByTestId('trending-tokens-list')).toBeOnTheScreen(); @@ -639,17 +481,10 @@ describe('TrendingTokensFullView', () => { }); it('returns empty array when search results are empty', () => { - mockUseTrendingSearch.mockReturnValue({ - data: [], - isLoading: false, - refetch: jest.fn(), - }); + const mocks = arrangeMocks(); + mocks.setTrendingSearchMock({ data: [] }); - const { getByTestId } = renderWithProvider( - , - { state: mockState }, - false, - ); + const { getByTestId } = renderTrendingFullView(); // Should show empty state, not the tokens list expect(getByTestId('empty-error-trending-state')).toBeOnTheScreen(); @@ -674,27 +509,18 @@ describe('TrendingTokensFullView', () => { }), ]; - mockUseTrendingSearch.mockReturnValue({ - data: mockTokens, - isLoading: false, - refetch: jest.fn(), - }); + const mocks = arrangeMocks(); + mocks.setTrendingSearchMock({ data: mockTokens }); - const { getByTestId, getByText } = renderWithProvider( - , - { state: mockState }, - false, - ); + const { getByTestId, getByText } = renderTrendingFullView(); // Open price change bottom sheet const priceChangeButton = getByTestId('price-change-button'); - fireEvent.press(priceChangeButton); + await userEvent.press(priceChangeButton); // Select Volume option const volumeOption = getByTestId('price-change-select-volume'); - await act(async () => { - fireEvent(volumeOption, 'touchEnd'); - }); + await userEvent.press(volumeOption); // The tokens should now be sorted by volume // Note: The actual sorting is done by sortTrendingTokens utility @@ -719,30 +545,21 @@ describe('TrendingTokensFullView', () => { }), ]; - mockUseTrendingSearch.mockReturnValue({ - data: mockTokens, - isLoading: false, - refetch: jest.fn(), - }); + const mocks = arrangeMocks(); + mocks.setTrendingSearchMock({ data: mockTokens }); - const { getByTestId, getByText } = renderWithProvider( - , - { state: mockState }, - false, - ); + const { getByTestId, getByText } = renderTrendingFullView(); // First select a price change option const priceChangeButton = getByTestId('price-change-button'); - fireEvent.press(priceChangeButton); + await userEvent.press(priceChangeButton); const volumeOption = getByTestId('price-change-select-volume'); - await act(async () => { - fireEvent(volumeOption, 'touchEnd'); - }); + await userEvent.press(volumeOption); // Now open search and type a query const searchToggle = getByTestId('trending-tokens-header-search-toggle'); - fireEvent.press(searchToggle); + await userEvent.press(searchToggle); const searchInput = getByTestId('trending-tokens-header-search-bar'); fireEvent.changeText(searchInput, 'eth'); @@ -765,21 +582,15 @@ describe('TrendingTokensFullView', () => { }), ]; - mockUseTrendingSearch.mockReturnValue({ - data: mockTokens, - isLoading: false, - refetch: jest.fn(), - }); + const mocks = arrangeMocks(); + mocks.setTrendingSearchMock({ data: mockTokens }); - const { getByTestId, getByText, queryByTestId } = renderWithProvider( - , - { state: mockState }, - false, - ); + const { getByTestId, getByText, queryByTestId } = + renderTrendingFullView(); // Open search const searchToggle = getByTestId('trending-tokens-header-search-toggle'); - fireEvent.press(searchToggle); + await userEvent.press(searchToggle); // Type search query const searchInput = getByTestId('trending-tokens-header-search-bar'); diff --git a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx b/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx index 746e76cbe4d..611914d5ab1 100644 --- a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx +++ b/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx @@ -46,6 +46,7 @@ import { sortTrendingTokens } from '../../../UI/Trending/utils/sortTrendingToken import { useTrendingSearch } from '../../../UI/Trending/hooks/useTrendingSearch/useTrendingSearch'; import EmptyErrorTrendingState from '../../TrendingView/components/EmptyErrorState/EmptyErrorTrendingState'; import EmptySearchResultState from '../../TrendingView/components/EmptyErrorState/EmptySearchResultState'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; import TrendingFeedSessionManager from '../../../UI/Trending/services/TrendingFeedSessionManager'; import { useSearchTracking } from '../../../UI/Trending/hooks/useSearchTracking/useSearchTracking'; @@ -68,11 +69,6 @@ const createStyles = (theme: Theme) => backgroundColor: theme.colors.background.muted, padding: 16, }, - listContainer: { - flex: 1, - paddingLeft: 16, - paddingRight: 16, - }, controlBarWrapper: { paddingVertical: 16, paddingHorizontal: 16, @@ -133,6 +129,79 @@ const createStyles = (theme: Theme) => }, }); +export interface TrendingTokensDataProps { + isLoading: boolean; + refreshing: boolean; + trendingTokens: TrendingAsset[]; + handleRefresh: () => void; + selectedTimeOption: TimeOption; + filterContext: TrendingFilterContext; + theme: Theme; + + search: { + searchResults: TrendingAsset[]; + searchQuery: string; + }; +} + +export const TrendingTokensData = (props: TrendingTokensDataProps) => { + const { + isLoading, + refreshing, + trendingTokens, + search, + handleRefresh, + selectedTimeOption, + filterContext, + theme, + } = props; + + const tw = useTailwind(); + + const isSearching = search.searchQuery.trim().length > 0; + const hasSearchResults = search.searchResults.length > 0; + + // Loading - show skeleton + if (isLoading) { + return ( + + {Array.from({ length: 12 }).map((_, index) => ( + + ))} + + ); + } + + // Show empty trending search results + if (isSearching && !hasSearchResults) { + return ; + } + + // Show error if no results found + if (!isSearching && !hasSearchResults) { + return ; + } + + // Show trending tokens list + return ( + + + } + /> + + ); +}; + const TrendingTokensFullView = () => { const navigation = useNavigation>(); @@ -515,35 +584,17 @@ const TrendingTokensFullView = () => { ) : null} - {isLoading ? ( - - {Array.from({ length: 12 }).map((_, index) => ( - - ))} - - ) : (searchResults as TrendingAsset[]).length === 0 ? ( - searchQuery.trim().length > 0 ? ( - - ) : ( - - ) - ) : ( - - - } - /> - - )} + + setShowTimeBottomSheet(false)} diff --git a/app/components/Views/TrendingView/components/EmptyErrorState/EmptyErrorTrendingState.tsx b/app/components/Views/TrendingView/components/EmptyErrorState/EmptyErrorTrendingState.tsx index 2509eb094eb..c453c7a0e26 100644 --- a/app/components/Views/TrendingView/components/EmptyErrorState/EmptyErrorTrendingState.tsx +++ b/app/components/Views/TrendingView/components/EmptyErrorState/EmptyErrorTrendingState.tsx @@ -37,6 +37,7 @@ const EmptyErrorTrendingState: React.FC = ({ variant={ButtonVariant.Primary} twClassName="self-stretch mt-6" onPress={onRetry} + testID="empty-error-trending-state--retry-button" > {strings('trending.empty_error_trending_state.try_again')} diff --git a/app/components/Views/Wallet/index.test.tsx b/app/components/Views/Wallet/index.test.tsx index 241ef1870ce..9ed870184ee 100644 --- a/app/components/Views/Wallet/index.test.tsx +++ b/app/components/Views/Wallet/index.test.tsx @@ -198,6 +198,9 @@ jest.mock('../../../core/Engine', () => { TokensController: { addTokens: jest.fn(), }, + RampsController: { + hydrateState: jest.fn(), + }, NetworkEnablementController: { setEnabledNetwork: jest.fn(), setDisabledNetwork: jest.fn(), diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx index b320627c20c..5b97c7000e1 100644 --- a/app/components/Views/Wallet/index.tsx +++ b/app/components/Views/Wallet/index.tsx @@ -177,6 +177,7 @@ import { selectSelectedInternalAccountByScope } from '../../../selectors/multich import { EVM_SCOPE } from '../../UI/Earn/constants/networks'; import { useCurrentNetworkInfo } from '../../hooks/useCurrentNetworkInfo'; import { createAddressListNavigationDetails } from '../../Views/MultichainAccounts/AddressList'; +import { useHydrateRampsController } from '../../UI/Ramp/hooks/useHydrateRampsController'; import NftGrid from '../../UI/NftGrid/NftGrid'; import { AssetPollingProvider } from '../../hooks/AssetPolling/AssetPollingProvider'; import { selectDisplayCardButton } from '../../../core/redux/slices/card'; @@ -587,6 +588,7 @@ const Wallet = ({ const { toastRef } = useContext(ToastContext); const { trackEvent, createEventBuilder, addTraitsToUser } = useMetrics(); + useHydrateRampsController(); const styles = useMemo(() => createStyles(theme), [theme]); const { colors } = theme; const dispatch = useDispatch(); diff --git a/app/components/Views/confirmations/components/UI/Tooltip/Tooltip.styles.ts b/app/components/Views/confirmations/components/UI/Tooltip/Tooltip.styles.ts index 1dbbd66ee2a..f1e79ced4a7 100644 --- a/app/components/Views/confirmations/components/UI/Tooltip/Tooltip.styles.ts +++ b/app/components/Views/confirmations/components/UI/Tooltip/Tooltip.styles.ts @@ -17,7 +17,6 @@ const styleSheet = (params: { theme: Theme }) => { borderRadius: 8, ...theme.shadows.size.sm, elevation: 11, - paddingHorizontal: 16, paddingVertical: 24, }, modalHeader: { @@ -41,7 +40,7 @@ const styleSheet = (params: { theme: Theme }) => { backgroundColor: theme.colors.background.default, marginTop: 16, paddingVertical: 20, - paddingHorizontal: 20, + paddingHorizontal: 16, borderRadius: 8, display: 'flex', alignItems: 'center', diff --git a/app/components/Views/confirmations/components/UI/Tooltip/Tooltip.tsx b/app/components/Views/confirmations/components/UI/Tooltip/Tooltip.tsx index dc815c99ad3..3c3fc6078a5 100644 --- a/app/components/Views/confirmations/components/UI/Tooltip/Tooltip.tsx +++ b/app/components/Views/confirmations/components/UI/Tooltip/Tooltip.tsx @@ -9,6 +9,7 @@ import { } from '../../../../../../component-library/components/Icons/Icon'; import Text from '../../../../../../component-library/components/Texts/Text'; import { useStyles } from '../../../../../../component-library/hooks'; +import HeaderCenter from '../../../../../../component-library/components-temp/HeaderCenter'; import BottomModal from '../bottom-modal'; import styleSheet from './Tooltip.styles'; @@ -43,17 +44,13 @@ export const TooltipModal = ({ return ( setOpen(false)} isTooltip> - - setOpen(false)} - size={ButtonIconSizes.Sm} - style={styles.closeModalBtn} - testID={`${tooltipTestId}-close-btn`} - /> - {{title ?? ''}} - + setOpen(false)} + closeButtonProps={{ + testID: `${tooltipTestId}-close-btn`, + }} + /> {typeof content === 'string' ? ( {content} diff --git a/app/components/Views/confirmations/components/UI/expandable/expandable.styles.ts b/app/components/Views/confirmations/components/UI/expandable/expandable.styles.ts index f7db6e9e355..6896aa48bb3 100644 --- a/app/components/Views/confirmations/components/UI/expandable/expandable.styles.ts +++ b/app/components/Views/confirmations/components/UI/expandable/expandable.styles.ts @@ -1,7 +1,6 @@ import { StyleSheet } from 'react-native'; import { Theme } from '../../../../../../util/theme/models'; -import { fontStyles } from '../../../../../../styles/common'; const styleSheet = (params: { theme: Theme; @@ -25,21 +24,11 @@ const styleSheet = (params: { backgroundColor: theme.colors.background.alternative, paddingTop: 24, paddingBottom: 34, - paddingHorizontal: 16, borderTopLeftRadius: 8, borderTopRightRadius: 8, }, - modalHeader: { - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - paddingBottom: 16, - }, - expandedContentTitle: { - color: theme.colors.text.default, - ...fontStyles.bold, - width: '90%', - textAlign: 'center', + modalExpandedContent: { + paddingHorizontal: 16, }, }); }; diff --git a/app/components/Views/confirmations/components/UI/expandable/expandable.tsx b/app/components/Views/confirmations/components/UI/expandable/expandable.tsx index 2de11a6d031..624c8ee0171 100644 --- a/app/components/Views/confirmations/components/UI/expandable/expandable.tsx +++ b/app/components/Views/confirmations/components/UI/expandable/expandable.tsx @@ -1,15 +1,8 @@ import React, { ReactNode, useState } from 'react'; import { TouchableOpacity, View } from 'react-native'; -import ButtonIcon, { - ButtonIconSizes, -} from '../../../../../../component-library/components/Buttons/ButtonIcon'; -import { - IconColor, - IconName, -} from '../../../../../../component-library/components/Icons/Icon'; -import Text from '../../../../../../component-library/components/Texts/Text'; import { useStyles } from '../../../../../../component-library/hooks'; +import HeaderCenter from '../../../../../../component-library/components-temp/HeaderCenter'; import BottomModal from '../bottom-modal'; import styleSheet from './expandable.styles'; @@ -52,19 +45,14 @@ const Expandable = ({ {expanded && ( setExpanded(false)}> - - setExpanded(false)} - iconName={IconName.ArrowLeft} - testID={collapseButtonTestID ?? 'collapseButtonTestID'} - /> - - {expandedContentTitle} - - - {expandedContent} + setExpanded(false)} + closeButtonProps={{ + testID: collapseButtonTestID ?? 'collapseButtonTestID', + }} + /> + {expandedContent} )} diff --git a/app/components/Views/confirmations/components/confirm/confirm-component.test.tsx b/app/components/Views/confirmations/components/confirm/confirm-component.test.tsx index 37b918b0d10..3694a5511dc 100644 --- a/app/components/Views/confirmations/components/confirm/confirm-component.test.tsx +++ b/app/components/Views/confirmations/components/confirm/confirm-component.test.tsx @@ -508,13 +508,13 @@ describe('Confirm', () => { }); }); - it('sets navigation options with header shown for full screen confirmations', () => { + it('sets navigation options with header hidden for full screen confirmations', () => { renderWithProvider(, { state: stakingDepositConfirmationState, }); expect(mockSetOptions).toHaveBeenCalledWith({ - headerShown: true, + headerShown: false, gestureEnabled: true, }); }); diff --git a/app/components/Views/confirmations/components/confirm/confirm-component.tsx b/app/components/Views/confirmations/components/confirm/confirm-component.tsx index e11f5650014..4a36c698367 100755 --- a/app/components/Views/confirmations/components/confirm/confirm-component.tsx +++ b/app/components/Views/confirmations/components/confirm/confirm-component.tsx @@ -12,6 +12,8 @@ import { useNavigation } from '@react-navigation/native'; import { ConfirmationUIType } from '../../ConfirmationView.testIds'; import BottomSheet from '../../../../../component-library/components/BottomSheets/BottomSheet'; import { useStyles } from '../../../../../component-library/hooks'; +import HeaderCenter from '../../../../../component-library/components-temp/HeaderCenter'; +import { strings } from '../../../../../../locales/i18n'; import { UnstakeConfirmationViewProps } from '../../../../UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.types'; import useConfirmationAlerts from '../../hooks/alerts/useConfirmationAlerts'; import useApprovalRequest from '../../hooks/useApprovalRequest'; @@ -124,19 +126,14 @@ export const Confirm = ({ useEffect(() => { if (approvalRequest) { - const options = { + navigation.setOptions({ + // HeaderCenter is used for full screen confirmations, so we don't need React Navigation's header headerShown: false, // If there is an approvalRequest, we need to allow the user to swipe to reject the confirmation gestureEnabled: true, - }; - - if (isFullScreenConfirmation) { - // If the confirmation is full screen, we need to show the header - options.headerShown = true; - } - navigation.setOptions(options); + }); } - }, [approvalRequest, isFullScreenConfirmation, navigation]); + }, [approvalRequest, navigation]); useEffect(() => { if (!approvalRequest) { @@ -165,6 +162,11 @@ export const Confirm = ({ style={[styles.flatContainer, fullscreenStyle]} testID={ConfirmationUIType.FLAT} > + ); diff --git a/app/components/Views/confirmations/components/gas/gas-fee-token-modal/gas-fee-token-modal.styles.ts b/app/components/Views/confirmations/components/gas/gas-fee-token-modal/gas-fee-token-modal.styles.ts index d49503e1743..afc2fbb912f 100644 --- a/app/components/Views/confirmations/components/gas/gas-fee-token-modal/gas-fee-token-modal.styles.ts +++ b/app/components/Views/confirmations/components/gas/gas-fee-token-modal/gas-fee-token-modal.styles.ts @@ -12,8 +12,6 @@ const styleSheet = (params: { backgroundColor: theme.colors.background.default, borderTopRightRadius: 16, borderTopLeftRadius: 16, - paddingLeft: 16, - paddingRight: 16, paddingBottom: 16, }, titleText: { diff --git a/app/components/Views/confirmations/components/gas/gas-fee-token-modal/gas-fee-token-modal.test.tsx b/app/components/Views/confirmations/components/gas/gas-fee-token-modal/gas-fee-token-modal.test.tsx index 2fbc8301738..f0b18430512 100644 --- a/app/components/Views/confirmations/components/gas/gas-fee-token-modal/gas-fee-token-modal.test.tsx +++ b/app/components/Views/confirmations/components/gas/gas-fee-token-modal/gas-fee-token-modal.test.tsx @@ -142,16 +142,16 @@ describe('GasFeeTokenModal', () => { jest.clearAllMocks(); }); - it('renders modal, header, back button', () => { + it('renders modal, header, close button', () => { const { getByTestId, getByText } = setupTest(); expect(getByTestId('gas-fee-token-modal')).toBeTruthy(); - expect(getByTestId('back-button')).toBeTruthy(); + expect(getByTestId('close-button')).toBeTruthy(); expect(getByText('Select a token')).toBeTruthy(); }); - it('calls onClose when back button is pressed', () => { + it('calls onClose when close button is pressed', () => { const { getByTestId } = setupTest(); - fireEvent.press(getByTestId('back-button')); + fireEvent.press(getByTestId('close-button')); expect(mockOnClose).toHaveBeenCalled(); }); 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 aff627afe3c..b98598b01e5 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,11 +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 Text, { - TextVariant, -} from '../../../../../../component-library/components/Texts/Text'; -import { IconName } from '../../../../../../component-library/components/Icons/Icon'; -import ButtonIcon from '../../../../../../component-library/components/Buttons/ButtonIcon'; +import HeaderCenter from '../../../../../../component-library/components-temp/HeaderCenter'; import styleSheet from './gas-fee-token-modal.styles'; import { GasFeeTokenListItem } from '../gas-fee-token-list-item'; import { Hex } from '@metamask/utils'; @@ -60,18 +56,11 @@ export function GasFeeTokenModal({ onClose }: { onClose?: () => void }) { } > - - - - - - {strings('gas_fee_token_modal.title')} - - + {gasFeeTokenAddresses.map((tokenAddress: Hex) => ( { expect(getByTestId('gas-fee-token-modal')).toBeOnTheScreen(); // Close modal - fireEvent.press(getByTestId('back-button')); + fireEvent.press(getByTestId('close-button')); expect(queryByTestId('gas-fee-token-modal')).toBeNull(); }); diff --git a/app/components/Views/confirmations/components/modals/estimates-modal/estimates-modal.styles.ts b/app/components/Views/confirmations/components/modals/estimates-modal/estimates-modal.styles.ts index 4c9a4411aaf..cfcfe5e29e4 100644 --- a/app/components/Views/confirmations/components/modals/estimates-modal/estimates-modal.styles.ts +++ b/app/components/Views/confirmations/components/modals/estimates-modal/estimates-modal.styles.ts @@ -7,8 +7,7 @@ const styleSheet = (params: { theme: Theme }) => { return StyleSheet.create({ container: { backgroundColor: theme.colors.background.default, - padding: 16, - paddingBottom: 36, + paddingBottom: 16, borderTopRightRadius: 16, borderTopLeftRadius: 16, }, 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 eeff6a36722..0833519c23c 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 { GasModalHeader } from '../../../components/gas/gas-modal-header'; +import HeaderCenter from '../../../../../../component-library/components-temp/HeaderCenter'; import { GasModalType } from '../../../constants/gas'; import styleSheet from './estimates-modal.styles'; @@ -27,9 +27,9 @@ export const EstimatesModal = ({ onSwipeComplete={handleCloseModals} > - {options.map((option) => ( diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index 268bc5b46fb..18ccb245da6 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -248,7 +248,8 @@ const Routes = { TOKEN_SELECTOR: 'BridgeTokenSelector', MODALS: { ROOT: 'BridgeModals', - SLIPPAGE_MODAL: 'SlippageModal', + DEFAULT_SLIPPAGE_MODAL: 'DefaultSlippageModal', + CUSTOM_SLIPPAGE_MODAL: 'CustomSlippageModal', TRANSACTION_DETAILS_BLOCK_EXPLORER: 'TransactionDetailsBlockExplorer', QUOTE_EXPIRED_MODAL: 'QuoteExpiredModal', BLOCKAID_MODAL: 'BlockaidModal', diff --git a/app/core/AppConstants.ts b/app/core/AppConstants.ts index caa620fdd26..2494223c390 100644 --- a/app/core/AppConstants.ts +++ b/app/core/AppConstants.ts @@ -30,6 +30,43 @@ export default { BRIDGE: { ACTIVE: true, URL: `${PORTFOLIO_URL}/bridge`, + // Check app/components/UI/Bridge/types.ts + // for interface definition. + SLIPPAGE_CONFIG: { + __default__: { + input_step: 0.1, + max_amount: 100, + min_amount: 0, + input_max_decimals: 2, + lower_allowed_slippage_threshold: { + messageId: 'bridge.exceeding_lower_slippage_error', + value: 0.1, + inclusive: true, + }, + lower_suggested_slippage_threshold: { + messageId: 'bridge.exceeding_lower_slippage_warning', + value: 0.5, + inclusive: false, + }, + upper_suggested_slippage_threshold: { + messageId: 'bridge.exceeding_upper_slippage_warning', + value: 5, + inclusive: false, + }, + upper_allowed_slippage_threshold: { + messageId: 'bridge.exceeding_upper_slippage_error', + value: 100, + inclusive: false, + }, + default_slippage_options: ['0.5', '2', '3'], + has_custom_slippage_option: true, + }, + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { + default_slippage_options: ['auto', '0.5', '2'], + }, + }, + }, }, STAKE: { URL: `${PORTFOLIO_URL}/stake`, diff --git a/app/core/Authentication/Authentication.ts b/app/core/Authentication/Authentication.ts index df4f196253a..ba3607552a9 100644 --- a/app/core/Authentication/Authentication.ts +++ b/app/core/Authentication/Authentication.ts @@ -19,6 +19,7 @@ import { import { setCompletedOnboarding } from '../../actions/onboarding'; import AUTHENTICATION_TYPE from '../../constants/userProperties'; import AuthenticationError from './AuthenticationError'; +import { UNLOCK_WALLET_ERROR_MESSAGES } from './constants'; import { UserCredentials, BIOMETRY_TYPE } from 'react-native-keychain'; import { AUTHENTICATION_APP_TRIGGERED_AUTH_ERROR, @@ -1680,7 +1681,12 @@ class AuthenticationService { ); } - if (errorWithMessage.message === 'Invalid password') { + if ( + errorWithMessage.message === 'Invalid password' || + errorWithMessage.message.includes( + UNLOCK_WALLET_ERROR_MESSAGES.ANDROID_WRONG_PASSWORD_2, + ) + ) { Alert.alert( strings('app_settings.invalid_password'), strings('app_settings.invalid_password_message'), diff --git a/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts b/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts index e1d8ac1d529..9a0dee09a1c 100644 --- a/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts +++ b/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts @@ -23,13 +23,13 @@ const createMockUserRegion = (regionCode: string): UserRegion => { name: countryCode, phone: { prefix: '', placeholder: '', template: '' }, currency: '', - supported: true, + supported: { buy: true, sell: true }, }, state: stateCode ? { stateId: stateCode, name: stateCode, - supported: true, + supported: { buy: true, sell: true }, } : null, regionCode: regionCode.toLowerCase(), @@ -91,12 +91,14 @@ describe('ramps controller init', () => { it('uses initial state when initial state is passed in', () => { const initialRampsControllerState: RampsControllerState = { userRegion: createMockUserRegion('us-ca'), - preferredProvider: null, + selectedProvider: null, providers: [], tokens: null, + selectedToken: null, requests: {}, paymentMethods: [], selectedPaymentMethod: null, + countries: [], }; initRequestMock.persistedState = { diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts index e2d1f1ab78b..36f9307d637 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts @@ -1038,6 +1038,922 @@ describe('RewardsController', () => { jest.useRealTimers(); }); + + it('should add successful estimate to history with flattened swap context', async () => { + const mockSwapContext = { + srcAsset: { + id: 'eip155:1/slip44:60' as const, + amount: '1000000000000000000', + usdPrice: '2500.00', + }, + destAsset: { + id: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as const, + amount: '2500000000', + usdPrice: '1.00', + }, + feeAsset: { + id: 'eip155:1/slip44:60' as const, + amount: '5000000000000000', + usdPrice: '2500.00', + }, + }; + + const mockRequest = { + activityType: 'SWAP' as const, + account: CAIP_ACCOUNT_1, + activityContext: { + swapContext: mockSwapContext, + }, + }; + + const mockResponse = { + pointsEstimate: 150, + bonusBips: 300, + }; + + const now = 1700000000000; + const mockSeasonId = 'season123'; + const mockSeasonMetadata = { + id: mockSeasonId, + name: 'Test Season', + startDate: new Date(now - 86400000), + endDate: new Date(now + 86400000), + tiers: createTestTiers(), + activityTypes: [], + }; + + jest.useFakeTimers(); + jest.setSystemTime(now); + + mockMessenger.call.mockImplementation((method, ..._args): any => { + if (method === 'RewardsDataService:getDiscoverSeasons') { + return Promise.resolve({ + current: { + id: mockSeasonId, + startDate: new Date(now - 86400000), + endDate: new Date(now + 86400000), + }, + next: null, + previous: null, + }); + } + if (method === 'RewardsDataService:getSeasonMetadata') { + return Promise.resolve(mockSeasonMetadata); + } + if (method === 'RewardsDataService:estimatePoints') { + return Promise.resolve(mockResponse); + } + return Promise.resolve(null); + }); + + expect(controller.state.pointsEstimateHistory).toHaveLength(0); + + await controller.estimatePoints(mockRequest); + + expect(controller.state.pointsEstimateHistory).toHaveLength(1); + const historyEntry = controller.state.pointsEstimateHistory[0]; + + // Verify flattened request fields + expect(historyEntry.requestActivityType).toBe(mockRequest.activityType); + expect(historyEntry.requestAccount).toBe(mockRequest.account); + + // Verify flattened swap source asset fields + expect(historyEntry.requestSwapSrcAssetId).toBe( + mockSwapContext.srcAsset.id, + ); + expect(historyEntry.requestSwapSrcAssetAmount).toBe( + mockSwapContext.srcAsset.amount, + ); + expect(historyEntry.requestSwapSrcAssetUsdPrice).toBe( + mockSwapContext.srcAsset.usdPrice, + ); + + // Verify flattened swap destination asset fields + expect(historyEntry.requestSwapDestAssetId).toBe( + mockSwapContext.destAsset.id, + ); + expect(historyEntry.requestSwapDestAssetAmount).toBe( + mockSwapContext.destAsset.amount, + ); + expect(historyEntry.requestSwapDestAssetUsdPrice).toBe( + mockSwapContext.destAsset.usdPrice, + ); + + // Verify flattened swap fee asset fields + expect(historyEntry.requestSwapFeeAssetId).toBe( + mockSwapContext.feeAsset.id, + ); + expect(historyEntry.requestSwapFeeAssetAmount).toBe( + mockSwapContext.feeAsset.amount, + ); + expect(historyEntry.requestSwapFeeAssetUsdPrice).toBe( + mockSwapContext.feeAsset.usdPrice, + ); + + // Verify flattened response fields + expect(historyEntry.responsePointsEstimate).toBe( + mockResponse.pointsEstimate, + ); + expect(historyEntry.responseBonusBips).toBe(mockResponse.bonusBips); + expect(historyEntry.timestamp).toBe(now); + + jest.useRealTimers(); + }); + + it('should add successful estimate to history with flattened perps context', async () => { + const mockPerpsContext = { + type: 'OPEN_POSITION' as const, + usdFeeValue: '12.34', + coin: 'ETH', + }; + + const mockRequest = { + activityType: 'PERPS' as const, + account: CAIP_ACCOUNT_1, + activityContext: { + perpsContext: mockPerpsContext, + }, + }; + + const mockResponse = { + pointsEstimate: 200, + bonusBips: 100, + }; + + const now = 1700000000000; + const mockSeasonId = 'season123'; + const mockSeasonMetadata = { + id: mockSeasonId, + name: 'Test Season', + startDate: new Date(now - 86400000), + endDate: new Date(now + 86400000), + tiers: createTestTiers(), + activityTypes: [], + }; + + jest.useFakeTimers(); + jest.setSystemTime(now); + + mockMessenger.call.mockImplementation((method, ..._args): any => { + if (method === 'RewardsDataService:getDiscoverSeasons') { + return Promise.resolve({ + current: { + id: mockSeasonId, + startDate: new Date(now - 86400000), + endDate: new Date(now + 86400000), + }, + next: null, + previous: null, + }); + } + if (method === 'RewardsDataService:getSeasonMetadata') { + return Promise.resolve(mockSeasonMetadata); + } + if (method === 'RewardsDataService:estimatePoints') { + return Promise.resolve(mockResponse); + } + return Promise.resolve(null); + }); + + await controller.estimatePoints(mockRequest); + + const historyEntry = controller.state.pointsEstimateHistory[0]; + + // Verify flattened perps context fields + expect(historyEntry.requestPerpsType).toBe(mockPerpsContext.type); + expect(historyEntry.requestPerpsUsdFeeValue).toBe( + mockPerpsContext.usdFeeValue, + ); + expect(historyEntry.requestPerpsCoin).toBe(mockPerpsContext.coin); + + jest.useRealTimers(); + }); + + it('should add successful estimate to history with flattened predict context', async () => { + const mockPredictFeeAsset = { + id: 'eip155:1/slip44:60' as const, + amount: '1000000000000000', + usdPrice: '2.50', + }; + + const mockRequest = { + activityType: 'PREDICT' as const, + account: CAIP_ACCOUNT_1, + activityContext: { + predictContext: { + feeAsset: mockPredictFeeAsset, + }, + }, + }; + + const mockResponse = { + pointsEstimate: 50, + bonusBips: 0, + }; + + const now = 1700000000000; + const mockSeasonId = 'season123'; + const mockSeasonMetadata = { + id: mockSeasonId, + name: 'Test Season', + startDate: new Date(now - 86400000), + endDate: new Date(now + 86400000), + tiers: createTestTiers(), + activityTypes: [], + }; + + jest.useFakeTimers(); + jest.setSystemTime(now); + + mockMessenger.call.mockImplementation((method, ..._args): any => { + if (method === 'RewardsDataService:getDiscoverSeasons') { + return Promise.resolve({ + current: { + id: mockSeasonId, + startDate: new Date(now - 86400000), + endDate: new Date(now + 86400000), + }, + next: null, + previous: null, + }); + } + if (method === 'RewardsDataService:getSeasonMetadata') { + return Promise.resolve(mockSeasonMetadata); + } + if (method === 'RewardsDataService:estimatePoints') { + return Promise.resolve(mockResponse); + } + return Promise.resolve(null); + }); + + await controller.estimatePoints(mockRequest); + + const historyEntry = controller.state.pointsEstimateHistory[0]; + + // Verify flattened predict context fields + expect(historyEntry.requestPredictFeeAssetId).toBe( + mockPredictFeeAsset.id, + ); + expect(historyEntry.requestPredictFeeAssetAmount).toBe( + mockPredictFeeAsset.amount, + ); + expect(historyEntry.requestPredictFeeAssetUsdPrice).toBe( + mockPredictFeeAsset.usdPrice, + ); + + jest.useRealTimers(); + }); + + it('should add successful estimate to history with flattened shield context', async () => { + const mockShieldFeeAsset = { + id: 'eip155:1/slip44:60' as const, + amount: '2000000000000000', + usdPrice: '5.00', + }; + + const mockRequest = { + activityType: 'SHIELD' as const, + account: CAIP_ACCOUNT_1, + activityContext: { + shieldContext: { + feeAsset: mockShieldFeeAsset, + }, + }, + }; + + const mockResponse = { + pointsEstimate: 75, + bonusBips: 50, + }; + + const now = 1700000000000; + const mockSeasonId = 'season123'; + const mockSeasonMetadata = { + id: mockSeasonId, + name: 'Test Season', + startDate: new Date(now - 86400000), + endDate: new Date(now + 86400000), + tiers: createTestTiers(), + activityTypes: [], + }; + + jest.useFakeTimers(); + jest.setSystemTime(now); + + mockMessenger.call.mockImplementation((method, ..._args): any => { + if (method === 'RewardsDataService:getDiscoverSeasons') { + return Promise.resolve({ + current: { + id: mockSeasonId, + startDate: new Date(now - 86400000), + endDate: new Date(now + 86400000), + }, + next: null, + previous: null, + }); + } + if (method === 'RewardsDataService:getSeasonMetadata') { + return Promise.resolve(mockSeasonMetadata); + } + if (method === 'RewardsDataService:estimatePoints') { + return Promise.resolve(mockResponse); + } + return Promise.resolve(null); + }); + + await controller.estimatePoints(mockRequest); + + const historyEntry = controller.state.pointsEstimateHistory[0]; + + // Verify flattened shield context fields + expect(historyEntry.requestShieldFeeAssetId).toBe(mockShieldFeeAsset.id); + expect(historyEntry.requestShieldFeeAssetAmount).toBe( + mockShieldFeeAsset.amount, + ); + expect(historyEntry.requestShieldFeeAssetUsdPrice).toBe( + mockShieldFeeAsset.usdPrice, + ); + + jest.useRealTimers(); + }); + + it('should not add to history when rewards are disabled', async () => { + const isDisabled = () => true; + const disabledController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled, + }); + + const mockRequest = { + activityType: 'SWAP' as const, + account: CAIP_ACCOUNT_1, + activityContext: {}, + }; + + await disabledController.estimatePoints(mockRequest); + + expect(disabledController.state.pointsEstimateHistory).toHaveLength(0); + }); + + it('should limit history to 50 entries', async () => { + const mockRequest = { + activityType: 'SWAP' as const, + account: CAIP_ACCOUNT_1, + activityContext: {}, + }; + + const mockResponse = { + pointsEstimate: 100, + bonusBips: 0, + }; + + const now = 1700000000000; + const mockSeasonId = 'season123'; + const mockSeasonMetadata = { + id: mockSeasonId, + name: 'Test Season', + startDate: new Date(now - 86400000), + endDate: new Date(now + 86400000), + tiers: createTestTiers(), + activityTypes: [], + }; + + jest.useFakeTimers(); + jest.setSystemTime(now); + + mockMessenger.call.mockImplementation((method, ..._args): any => { + if (method === 'RewardsDataService:getDiscoverSeasons') { + return Promise.resolve({ + current: { + id: mockSeasonId, + startDate: new Date(now - 86400000), + endDate: new Date(now + 86400000), + }, + next: null, + previous: null, + }); + } + if (method === 'RewardsDataService:getSeasonMetadata') { + return Promise.resolve(mockSeasonMetadata); + } + if (method === 'RewardsDataService:estimatePoints') { + return Promise.resolve(mockResponse); + } + return Promise.resolve(null); + }); + + // Make 55 calls - should only keep last 50 + for (let i = 0; i < 55; i++) { + jest.setSystemTime(now + i); // Ensure unique timestamps + await controller.estimatePoints(mockRequest); + } + + expect(controller.state.pointsEstimateHistory).toHaveLength(50); + + jest.useRealTimers(); + }); + + it('should store estimates in history keyed by timestamp', async () => { + const mockRequest = { + activityType: 'SWAP' as const, + account: CAIP_ACCOUNT_1, + activityContext: {}, + }; + + const now = 1700000000000; + const mockSeasonId = 'season123'; + const mockSeasonMetadata = { + id: mockSeasonId, + name: 'Test Season', + startDate: new Date(now - 86400000), + endDate: new Date(now + 86400000), + tiers: createTestTiers(), + activityTypes: [], + }; + + jest.useFakeTimers(); + jest.setSystemTime(now); + + let callCount = 0; + mockMessenger.call.mockImplementation((method, ..._args): any => { + if (method === 'RewardsDataService:getDiscoverSeasons') { + return Promise.resolve({ + current: { + id: mockSeasonId, + startDate: new Date(now - 86400000), + endDate: new Date(now + 86400000), + }, + next: null, + previous: null, + }); + } + if (method === 'RewardsDataService:getSeasonMetadata') { + return Promise.resolve(mockSeasonMetadata); + } + if (method === 'RewardsDataService:estimatePoints') { + callCount += 1; + return Promise.resolve({ + pointsEstimate: callCount * 100, + bonusBips: 0, + }); + } + return Promise.resolve(null); + }); + + jest.setSystemTime(now); + await controller.estimatePoints(mockRequest); + jest.setSystemTime(now + 1000); + await controller.estimatePoints(mockRequest); + jest.setSystemTime(now + 2000); + await controller.estimatePoints(mockRequest); + + // Array is ordered by timestamp (most recent first) + const entries = controller.state.pointsEstimateHistory; + expect(entries).toHaveLength(3); + + // Verify order (most recent first) + expect(entries[0].responsePointsEstimate).toBe(300); + expect(entries[1].responsePointsEstimate).toBe(200); + expect(entries[2].responsePointsEstimate).toBe(100); + + // Verify timestamps are in descending order + expect(entries[0].timestamp).toBe(now + 2000); + expect(entries[1].timestamp).toBe(now + 1000); + expect(entries[2].timestamp).toBe(now); + + jest.useRealTimers(); + }); + + it('should return successful estimate even when history tracking fails', async () => { + const mockRequest = { + activityType: 'SWAP' as const, + account: CAIP_ACCOUNT_1, + activityContext: {}, + }; + + const mockResponse = { + pointsEstimate: 100, + bonusBips: 200, + }; + + const now = 1700000000000; + const mockSeasonId = 'season123'; + const mockSeasonMetadata = { + id: mockSeasonId, + name: 'Test Season', + startDate: new Date(now - 86400000), + endDate: new Date(now + 86400000), + tiers: createTestTiers(), + activityTypes: [], + }; + + jest.useFakeTimers(); + jest.setSystemTime(now); + + mockMessenger.call.mockImplementation((method, ..._args): any => { + if (method === 'RewardsDataService:getDiscoverSeasons') { + return Promise.resolve({ + current: { + id: mockSeasonId, + startDate: new Date(now - 86400000), + endDate: new Date(now + 86400000), + }, + next: null, + previous: null, + }); + } + if (method === 'RewardsDataService:getSeasonMetadata') { + return Promise.resolve(mockSeasonMetadata); + } + if (method === 'RewardsDataService:estimatePoints') { + return Promise.resolve(mockResponse); + } + return Promise.resolve(null); + }); + + // Spy on addPointsEstimateToHistory and make it throw + const historyError = new Error('State update failed'); + const addHistorySpy = jest + .spyOn(controller, 'addPointsEstimateToHistory') + .mockImplementation(() => { + throw historyError; + }); + + // Act - should not throw despite history tracking failure + const result = await controller.estimatePoints(mockRequest); + + // Assert - estimate should be returned successfully + expect(result).toEqual(mockResponse); + + // Verify the history method was called + expect(addHistorySpy).toHaveBeenCalledWith(mockRequest, mockResponse); + + // Verify the error was logged (not the main estimate error) + expect(Logger.log).toHaveBeenCalledWith( + 'RewardsController: Failed to add points estimate to history:', + 'State update failed', + ); + + // Verify the misleading "Failed to estimate points" was NOT logged + expect(Logger.log).not.toHaveBeenCalledWith( + 'RewardsController: Failed to estimate points:', + expect.anything(), + ); + + addHistorySpy.mockRestore(); + jest.useRealTimers(); + }); + }); + + describe('addPointsEstimateToHistory', () => { + it('adds entry to history with basic request/response fields', () => { + const now = 1700000000000; + jest.useFakeTimers(); + jest.setSystemTime(now); + + const request = { + activityType: 'SWAP' as const, + account: CAIP_ACCOUNT_1, + activityContext: {}, + }; + + const response = { + pointsEstimate: 100, + bonusBips: 200, + }; + + controller.addPointsEstimateToHistory(request, response); + + expect(controller.state.pointsEstimateHistory).toHaveLength(1); + const entry = controller.state.pointsEstimateHistory[0]; + + expect(entry.timestamp).toBe(now); + expect(entry.requestActivityType).toBe('SWAP'); + expect(entry.requestAccount).toBe(CAIP_ACCOUNT_1); + expect(entry.responsePointsEstimate).toBe(100); + expect(entry.responseBonusBips).toBe(200); + + jest.useRealTimers(); + }); + + it('flattens swap context fields into history entry', () => { + const now = 1700000000000; + jest.useFakeTimers(); + jest.setSystemTime(now); + + const mockSwapContext = { + srcAsset: { + id: 'eip155:1/slip44:60' as const, + amount: '1000000000000000000', + usdPrice: '2500.00', + }, + destAsset: { + id: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as const, + amount: '2500000000', + usdPrice: '1.00', + }, + feeAsset: { + id: 'eip155:1/slip44:60' as const, + amount: '5000000000000000', + usdPrice: '2500.00', + }, + }; + + const request = { + activityType: 'SWAP' as const, + account: CAIP_ACCOUNT_1, + activityContext: { + swapContext: mockSwapContext, + }, + }; + + const response = { + pointsEstimate: 150, + bonusBips: 300, + }; + + controller.addPointsEstimateToHistory(request, response); + + const entry = controller.state.pointsEstimateHistory[0]; + + expect(entry.requestSwapSrcAssetId).toBe('eip155:1/slip44:60'); + expect(entry.requestSwapSrcAssetAmount).toBe('1000000000000000000'); + expect(entry.requestSwapSrcAssetUsdPrice).toBe('2500.00'); + expect(entry.requestSwapDestAssetId).toBe( + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + ); + expect(entry.requestSwapDestAssetAmount).toBe('2500000000'); + expect(entry.requestSwapDestAssetUsdPrice).toBe('1.00'); + expect(entry.requestSwapFeeAssetId).toBe('eip155:1/slip44:60'); + expect(entry.requestSwapFeeAssetAmount).toBe('5000000000000000'); + expect(entry.requestSwapFeeAssetUsdPrice).toBe('2500.00'); + + jest.useRealTimers(); + }); + + it('flattens single perps context fields into history entry', () => { + const now = 1700000000000; + jest.useFakeTimers(); + jest.setSystemTime(now); + + const mockPerpsContext = { + type: 'CLOSE_POSITION' as const, + coin: 'BTC', + usdFeeValue: '15.75', + }; + + const request = { + activityType: 'PERPS' as const, + account: CAIP_ACCOUNT_1, + activityContext: { + perpsContext: mockPerpsContext, + }, + }; + + const response = { + pointsEstimate: 200, + bonusBips: 150, + }; + + controller.addPointsEstimateToHistory(request, response); + + const entry = controller.state.pointsEstimateHistory[0]; + + expect(entry.requestPerpsType).toBe('CLOSE_POSITION'); + expect(entry.requestPerpsCoin).toBe('BTC'); + expect(entry.requestPerpsUsdFeeValue).toBe('15.75'); + + jest.useRealTimers(); + }); + + it('excludes perps context fields when perpsContext is an array', () => { + const now = 1700000000000; + jest.useFakeTimers(); + jest.setSystemTime(now); + + const mockPerpsContextArray = [ + { + type: 'CLOSE_POSITION' as const, + coin: 'BTC', + usdFeeValue: '10.50', + }, + { + type: 'CLOSE_POSITION' as const, + coin: 'ETH', + usdFeeValue: '5.25', + }, + ]; + + const request = { + activityType: 'PERPS' as const, + account: CAIP_ACCOUNT_1, + activityContext: { + perpsContext: mockPerpsContextArray, + }, + }; + + const response = { + pointsEstimate: 300, + bonusBips: 100, + }; + + controller.addPointsEstimateToHistory(request, response); + + const entry = controller.state.pointsEstimateHistory[0]; + + // Perps fields should not be present when context is an array + expect(entry.requestPerpsType).toBeUndefined(); + expect(entry.requestPerpsCoin).toBeUndefined(); + expect(entry.requestPerpsUsdFeeValue).toBeUndefined(); + // Basic fields should still be present + expect(entry.requestActivityType).toBe('PERPS'); + expect(entry.responsePointsEstimate).toBe(300); + + jest.useRealTimers(); + }); + + it('flattens predict context fields into history entry', () => { + const now = 1700000000000; + jest.useFakeTimers(); + jest.setSystemTime(now); + + const request = { + activityType: 'PREDICT' as const, + account: CAIP_ACCOUNT_1, + activityContext: { + predictContext: { + feeAsset: { + id: 'eip155:8453/slip44:60' as const, + amount: '1000000000000000', + usdPrice: '2500.00', + }, + }, + }, + }; + + const response = { + pointsEstimate: 75, + bonusBips: 50, + }; + + controller.addPointsEstimateToHistory(request, response); + + const entry = controller.state.pointsEstimateHistory[0]; + + expect(entry.requestPredictFeeAssetId).toBe('eip155:8453/slip44:60'); + expect(entry.requestPredictFeeAssetAmount).toBe('1000000000000000'); + expect(entry.requestPredictFeeAssetUsdPrice).toBe('2500.00'); + + jest.useRealTimers(); + }); + + it('flattens shield context fields into history entry', () => { + const now = 1700000000000; + jest.useFakeTimers(); + jest.setSystemTime(now); + + const request = { + activityType: 'SHIELD' as const, + account: CAIP_ACCOUNT_1, + activityContext: { + shieldContext: { + feeAsset: { + id: 'eip155:1/slip44:60' as const, + amount: '2000000000000000', + usdPrice: '2500.00', + }, + }, + }, + }; + + const response = { + pointsEstimate: 50, + bonusBips: 25, + }; + + controller.addPointsEstimateToHistory(request, response); + + const entry = controller.state.pointsEstimateHistory[0]; + + expect(entry.requestShieldFeeAssetId).toBe('eip155:1/slip44:60'); + expect(entry.requestShieldFeeAssetAmount).toBe('2000000000000000'); + expect(entry.requestShieldFeeAssetUsdPrice).toBe('2500.00'); + + jest.useRealTimers(); + }); + + it('limits history to 50 entries', () => { + const now = 1700000000000; + jest.useFakeTimers(); + jest.setSystemTime(now); + + const request = { + activityType: 'SWAP' as const, + account: CAIP_ACCOUNT_1, + activityContext: {}, + }; + + const response = { + pointsEstimate: 100, + bonusBips: 0, + }; + + // Add 55 entries + for (let i = 0; i < 55; i++) { + jest.setSystemTime(now + i); + controller.addPointsEstimateToHistory(request, response); + } + + expect(controller.state.pointsEstimateHistory).toHaveLength(50); + + jest.useRealTimers(); + }); + + it('adds new entries at the beginning (most recent first)', () => { + const now = 1700000000000; + jest.useFakeTimers(); + jest.setSystemTime(now); + + const request1 = { + activityType: 'SWAP' as const, + account: CAIP_ACCOUNT_1, + activityContext: {}, + }; + + const request2 = { + activityType: 'PERPS' as const, + account: CAIP_ACCOUNT_2, + activityContext: {}, + }; + + const response1 = { + pointsEstimate: 100, + bonusBips: 0, + }; + + const response2 = { + pointsEstimate: 200, + bonusBips: 50, + }; + + jest.setSystemTime(now); + controller.addPointsEstimateToHistory(request1, response1); + + jest.setSystemTime(now + 1000); + controller.addPointsEstimateToHistory(request2, response2); + + const entries = controller.state.pointsEstimateHistory; + expect(entries).toHaveLength(2); + + // Most recent entry should be first + expect(entries[0].timestamp).toBe(now + 1000); + expect(entries[0].requestActivityType).toBe('PERPS'); + expect(entries[0].responsePointsEstimate).toBe(200); + + // Older entry should be second + expect(entries[1].timestamp).toBe(now); + expect(entries[1].requestActivityType).toBe('SWAP'); + expect(entries[1].responsePointsEstimate).toBe(100); + + jest.useRealTimers(); + }); + + it('preserves oldest entries within limit when trimming', () => { + const now = 1700000000000; + jest.useFakeTimers(); + jest.setSystemTime(now); + + const request = { + activityType: 'SWAP' as const, + account: CAIP_ACCOUNT_1, + activityContext: {}, + }; + + // Add 55 entries with different timestamps and points + for (let i = 0; i < 55; i++) { + jest.setSystemTime(now + i); + controller.addPointsEstimateToHistory(request, { + pointsEstimate: i + 1, + bonusBips: 0, + }); + } + + const entries = controller.state.pointsEstimateHistory; + + // Should have exactly 50 entries + expect(entries).toHaveLength(50); + + // Most recent entry (55th added) should be first + expect(entries[0].responsePointsEstimate).toBe(55); + expect(entries[0].timestamp).toBe(now + 54); + + // Oldest kept entry should be the 6th one added (indices 5-54 are kept) + expect(entries[49].responsePointsEstimate).toBe(6); + expect(entries[49].timestamp).toBe(now + 5); + + jest.useRealTimers(); + }); }); describe('hasActiveSeason', () => { @@ -14274,6 +15190,7 @@ describe('RewardsController', () => { "accounts": {}, "activeAccount": null, "activeBoosts": {}, + "pointsEstimateHistory": [], "pointsEvents": {}, "seasonStatuses": {}, "seasons": {}, @@ -14292,6 +15209,7 @@ describe('RewardsController', () => { "accounts": {}, "activeAccount": null, "activeBoosts": {}, + "pointsEstimateHistory": [], "pointsEvents": {}, "seasonStatuses": {}, "seasons": {}, diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.ts index 5ab3e29452e..cfc8c504701 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.ts @@ -21,6 +21,7 @@ import { type PointsBoostDto, type PointsEventDto, type RewardDto, + type PointsEstimateHistoryEntry, ClaimRewardDto, PointsEventsDtoState, GetPointsEventsLastUpdatedDto, @@ -94,6 +95,9 @@ const POINTS_EVENTS_CACHE_THRESHOLD_MS = 1000 * 60 * 1; // 1 minute cache // Opt-in status stale threshold for not opted-in accounts to force a fresh check const NOT_OPTED_IN_OIS_STALE_CACHE_THRESHOLD_MS = 1000 * 60 * 60; // 1 hour +// Maximum number of points estimate history entries to keep for Customer Support diagnostics +const MAX_POINTS_ESTIMATE_HISTORY_ENTRIES = 50; + /** * State metadata for the RewardsController */ @@ -152,6 +156,12 @@ const metadata: StateMetadata = { includeInDebugSnapshot: false, usedInUi: true, }, + pointsEstimateHistory: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: false, + }, }; /** @@ -167,6 +177,7 @@ export const getRewardsControllerDefaultState = (): RewardsControllerState => ({ activeBoosts: {}, unlockedRewards: {}, pointsEvents: {}, + pointsEstimateHistory: [], }); export const defaultRewardsControllerState = getRewardsControllerDefaultState(); @@ -1667,6 +1678,19 @@ export class RewardsController extends BaseController< request, ); + // Track successful estimate in history for Customer Support diagnostics. + // History tracking is a diagnostic side effect and should not affect the main functionality. + try { + this.addPointsEstimateToHistory(request, estimatedPoints); + } catch (historyError) { + Logger.log( + 'RewardsController: Failed to add points estimate to history:', + historyError instanceof Error + ? historyError.message + : String(historyError), + ); + } + return estimatedPoints; } catch (error) { Logger.log( @@ -1677,6 +1701,82 @@ export class RewardsController extends BaseController< } } + /** + * Add a successful points estimate to the history for Customer Support diagnostics. + * Maintains a bounded history of the last N estimates. + * @param request - The estimate request containing activity details + * @param response - The estimated points response + */ + addPointsEstimateToHistory( + request: EstimatePointsDto, + response: EstimatedPointsDto, + ): void { + const { activityContext } = request; + + const entry: PointsEstimateHistoryEntry = { + timestamp: Date.now(), + requestActivityType: request.activityType, + requestAccount: request.account, + // Swap context fields (if applicable) - flattened for easier diagnostics + ...(activityContext.swapContext && { + requestSwapSrcAssetId: activityContext.swapContext.srcAsset.id, + requestSwapSrcAssetAmount: activityContext.swapContext.srcAsset.amount, + requestSwapSrcAssetUsdPrice: + activityContext.swapContext.srcAsset.usdPrice, + requestSwapDestAssetId: activityContext.swapContext.destAsset.id, + requestSwapDestAssetAmount: + activityContext.swapContext.destAsset.amount, + requestSwapDestAssetUsdPrice: + activityContext.swapContext.destAsset.usdPrice, + requestSwapFeeAssetId: activityContext.swapContext.feeAsset.id, + requestSwapFeeAssetAmount: activityContext.swapContext.feeAsset.amount, + requestSwapFeeAssetUsdPrice: + activityContext.swapContext.feeAsset.usdPrice, + }), + // Perps context fields (if applicable) + ...(activityContext.perpsContext && + !Array.isArray(activityContext.perpsContext) && { + requestPerpsType: activityContext.perpsContext.type, + requestPerpsUsdFeeValue: activityContext.perpsContext.usdFeeValue, + requestPerpsCoin: activityContext.perpsContext.coin, + }), + // Predict context fields (if applicable) - flattened for easier diagnostics + ...(activityContext.predictContext && { + requestPredictFeeAssetId: activityContext.predictContext.feeAsset.id, + requestPredictFeeAssetAmount: + activityContext.predictContext.feeAsset.amount, + requestPredictFeeAssetUsdPrice: + activityContext.predictContext.feeAsset.usdPrice, + }), + // Shield context fields (if applicable) - flattened for easier diagnostics + ...(activityContext.shieldContext && { + requestShieldFeeAssetId: activityContext.shieldContext.feeAsset.id, + requestShieldFeeAssetAmount: + activityContext.shieldContext.feeAsset.amount, + requestShieldFeeAssetUsdPrice: + activityContext.shieldContext.feeAsset.usdPrice, + }), + // Response fields + responsePointsEstimate: response.pointsEstimate, + responseBonusBips: response.bonusBips, + }; + + this.update((state: RewardsControllerState) => { + // Add new entry at the beginning (most recent first) + state.pointsEstimateHistory.unshift(entry); + + // Keep only the last N entries + if ( + state.pointsEstimateHistory.length > MAX_POINTS_ESTIMATE_HISTORY_ENTRIES + ) { + state.pointsEstimateHistory = state.pointsEstimateHistory.slice( + 0, + MAX_POINTS_ESTIMATE_HISTORY_ENTRIES, + ); + } + }); + } + /** * Check if the rewards feature is enabled * @returns boolean - True if rewards feature is enabled, false otherwise @@ -1861,7 +1961,6 @@ export class RewardsController extends BaseController< season, seasonState, ); - return this.#convertSeasonStatusToSubscriptionState(seasonStatus); } catch (error) { if (error instanceof AuthorizationFailedError) { diff --git a/app/core/Engine/controllers/rewards-controller/types.ts b/app/core/Engine/controllers/rewards-controller/types.ts index ed8263b0058..f343821d195 100644 --- a/app/core/Engine/controllers/rewards-controller/types.ts +++ b/app/core/Engine/controllers/rewards-controller/types.ts @@ -152,6 +152,17 @@ export interface EstimatePredictContextDto { feeAsset: EstimateAssetDto; } +export interface EstimateShieldContextDto { + /** + * Fee asset information, in caip19 format + * @example { + * id: 'eip155:1/slip44:60', + * amount: '1000000000000000' + * } + */ + feeAsset: EstimateAssetDto; +} + export interface EstimatePointsContextDto { /** * Swap context data, must be present for SWAP activity @@ -171,6 +182,11 @@ export interface EstimatePointsContextDto { * Predict context data, must be present for PREDICT activity */ predictContext?: EstimatePredictContextDto; + + /** + * Shield context data, must be present for SHIELD activity + */ + shieldContext?: EstimateShieldContextDto; } /** @@ -718,6 +734,297 @@ export type RewardsAccountState = { lastFreshOptInStatusCheck?: number | null; }; +/** + * A single entry in the points estimate history. + * Used by Customer Support to verify points estimates shown to users. + * Structure is intentionally flat to simplify debugging and log analysis. + */ +export interface PointsEstimateHistoryEntry { + /** + * Timestamp when the estimate was made (milliseconds since epoch) + */ + timestamp: number; + + /** + * Type of point earning activity (from request) + * @example 'SWAP' + */ + requestActivityType: PointsEventEarnType; + + /** + * Account address performing the activity in CAIP-10 format (from request) + * @example 'eip155:1:0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6' + */ + requestAccount: CaipAccountId; + + /** + * Source asset ID for swap activity in CAIP-19 format (if applicable) + * @example 'eip155:1/slip44:60' + */ + requestSwapSrcAssetId?: CaipAssetType; + + /** + * Source asset amount for swap activity (if applicable) + * @example '1000000000000000000' + */ + requestSwapSrcAssetAmount?: string; + + /** + * Source asset USD price for swap activity (if applicable) + * @example '4512.34' + */ + requestSwapSrcAssetUsdPrice?: string; + + /** + * Destination asset ID for swap activity in CAIP-19 format (if applicable) + * @example 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + */ + requestSwapDestAssetId?: CaipAssetType; + + /** + * Destination asset amount for swap activity (if applicable) + * @example '4500000000' + */ + requestSwapDestAssetAmount?: string; + + /** + * Destination asset USD price for swap activity (if applicable) + * @example '1.00' + */ + requestSwapDestAssetUsdPrice?: string; + + /** + * Fee asset ID for swap activity in CAIP-19 format (if applicable) + * @example 'eip155:1/slip44:60' + */ + requestSwapFeeAssetId?: CaipAssetType; + + /** + * Fee asset amount for swap activity (if applicable) + * @example '5000000000000000' + */ + requestSwapFeeAssetAmount?: string; + + /** + * Fee asset USD price for swap activity (if applicable) + * @example '4512.34' + */ + requestSwapFeeAssetUsdPrice?: string; + + /** + * Type of PERPS action (if applicable) + * @example 'OPEN_POSITION' + */ + requestPerpsType?: EstimatePerpsContextDto['type']; + + /** + * USD fee value for PERPS activity (if applicable) + * @example '12.34' + */ + requestPerpsUsdFeeValue?: string; + + /** + * Asset symbol for PERPS activity (if applicable) + * @example 'ETH' + */ + requestPerpsCoin?: string; + + /** + * Predict fee asset ID in CAIP-19 format (if applicable) + * @example 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + */ + requestPredictFeeAssetId?: CaipAssetType; + + /** + * Predict fee asset amount (if applicable) + * @example '1000000000000000000' + */ + requestPredictFeeAssetAmount?: string; + + /** + * Predict fee asset USD price (if applicable) + * @example '4512.34' + */ + requestPredictFeeAssetUsdPrice?: string; + + /** + * Shield fee asset ID in CAIP-19 format (if applicable) + * @example 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + */ + requestShieldFeeAssetId?: CaipAssetType; + + /** + * Shield fee asset amount (if applicable) + * @example '1000000000000000000' + */ + requestShieldFeeAssetAmount?: string; + + /** + * Shield fee asset USD price (if applicable) + * @example '4512.34' + */ + requestShieldFeeAssetUsdPrice?: string; + + /** + * Estimated points earnable for the activity (from response) + * @example 100 + */ + responsePointsEstimate: number; + + /** + * Bonus applied to the points estimate, in basis points (from response) + * 100 = 1% + * @example 200 + */ + responseBonusBips: number; +} + +/** + * Serialized version of PointsEstimateHistoryEntry for state storage. + * Uses plain strings instead of branded CAIP types to satisfy StateConstraint (Json-serializable). + * This is the type stored in RewardsControllerState.pointsEstimateHistory. + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type PointsEstimateHistoryEntryState = { + /** + * Timestamp when the estimate was made (milliseconds since epoch) + */ + timestamp: number; + + /** + * Type of point earning activity (from request) + * @example 'SWAP' + */ + requestActivityType: string; + + /** + * Account address performing the activity in CAIP-10 format (stored as plain string) + * @example 'eip155:1:0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6' + */ + requestAccount: string; + + /** + * Source asset ID for swap activity in CAIP-19 format (stored as plain string) + * @example 'eip155:1/slip44:60' + */ + requestSwapSrcAssetId?: string; + + /** + * Source asset amount for swap activity (if applicable) + * @example '1000000000000000000' + */ + requestSwapSrcAssetAmount?: string; + + /** + * Source asset USD price for swap activity (if applicable) + * @example '4512.34' + */ + requestSwapSrcAssetUsdPrice?: string; + + /** + * Destination asset ID for swap activity in CAIP-19 format (stored as plain string) + * @example 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + */ + requestSwapDestAssetId?: string; + + /** + * Destination asset amount for swap activity (if applicable) + * @example '4500000000' + */ + requestSwapDestAssetAmount?: string; + + /** + * Destination asset USD price for swap activity (if applicable) + * @example '1.00' + */ + requestSwapDestAssetUsdPrice?: string; + + /** + * Fee asset ID for swap activity in CAIP-19 format (stored as plain string) + * @example 'eip155:1/slip44:60' + */ + requestSwapFeeAssetId?: string; + + /** + * Fee asset amount for swap activity (if applicable) + * @example '5000000000000000' + */ + requestSwapFeeAssetAmount?: string; + + /** + * Fee asset USD price for swap activity (if applicable) + * @example '4512.34' + */ + requestSwapFeeAssetUsdPrice?: string; + + /** + * Type of PERPS action (stored as plain string) + * @example 'OPEN_POSITION' + */ + requestPerpsType?: string; + + /** + * USD fee value for PERPS activity (if applicable) + * @example '12.34' + */ + requestPerpsUsdFeeValue?: string; + + /** + * Asset symbol for PERPS activity (if applicable) + * @example 'ETH' + */ + requestPerpsCoin?: string; + + /** + * Predict fee asset ID in CAIP-19 format (stored as plain string) + * @example 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + */ + requestPredictFeeAssetId?: string; + + /** + * Predict fee asset amount (if applicable) + * @example '1000000000000000000' + */ + requestPredictFeeAssetAmount?: string; + + /** + * Predict fee asset USD price (if applicable) + * @example '4512.34' + */ + requestPredictFeeAssetUsdPrice?: string; + + /** + * Shield fee asset ID in CAIP-19 format (stored as plain string) + * @example 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + */ + requestShieldFeeAssetId?: string; + + /** + * Shield fee asset amount (if applicable) + * @example '1000000000000000000' + */ + requestShieldFeeAssetAmount?: string; + + /** + * Shield fee asset USD price (if applicable) + * @example '4512.34' + */ + requestShieldFeeAssetUsdPrice?: string; + + /** + * Estimated points earnable for the activity (from response) + * @example 100 + */ + responsePointsEstimate: number; + + /** + * Bonus applied to the points estimate, in basis points (from response) + * 100 = 1% + * @example 200 + */ + responseBonusBips: number; +}; + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions export type RewardsControllerState = { activeAccount: RewardsAccountState | null; @@ -731,6 +1038,13 @@ export type RewardsControllerState = { activeBoosts: { [compositeId: string]: ActiveBoostsState }; unlockedRewards: { [compositeId: string]: UnlockedRewardsState }; pointsEvents: { [compositeId: string]: PointsEventsDtoState }; + /** + * History of points estimates for Customer Support diagnostics. + * Stores the last N successful estimates to verify user-reported discrepancies. + * Array is ordered by timestamp (most recent first) + * Uses PointsEstimateHistoryEntryState (plain strings) to satisfy StateConstraint. + */ + pointsEstimateHistory: PointsEstimateHistoryEntryState[]; }; /** diff --git a/app/core/Engine/messengers/token-list-controller-messenger.ts b/app/core/Engine/messengers/token-list-controller-messenger.ts index a406259feb7..f5a7bd7bdd6 100644 --- a/app/core/Engine/messengers/token-list-controller-messenger.ts +++ b/app/core/Engine/messengers/token-list-controller-messenger.ts @@ -32,7 +32,6 @@ export function getTokenListControllerMessenger( 'StorageService:getAllKeys', 'StorageService:setItem', 'StorageService:getItem', - 'StorageService:removeItem', ], events: ['NetworkController:stateChange'], messenger, diff --git a/app/core/OAuthService/OAuthLoginHandlers/baseHandler.test.ts b/app/core/OAuthService/OAuthLoginHandlers/baseHandler.test.ts index b0149364012..f388b7ea009 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/baseHandler.test.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/baseHandler.test.ts @@ -13,6 +13,16 @@ global.fetch = jest.fn(); const mockRandomUUID = jest.fn(); +// Mock Logger +jest.mock('../../../util/Logger', () => ({ + __esModule: true, + default: { + log: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + }, +})); + jest.mock('react-native-quick-crypto', () => ({ randomBytes: jest.fn().mockReturnValue(Buffer.from('mock-random-bytes')), randomUUID: () => mockRandomUUID(), diff --git a/app/core/OAuthService/OAuthLoginHandlers/baseHandler.ts b/app/core/OAuthService/OAuthLoginHandlers/baseHandler.ts index eaea36a99a3..e4bf9e9266b 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/baseHandler.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/baseHandler.ts @@ -6,7 +6,13 @@ import { LoginHandlerResult, } from '../OAuthInterface'; import { OAuthError, OAuthErrorType } from '../error'; -import { fromBase64UrlSafe, toBase64UrlSafe } from './utils'; +import { + fromBase64UrlSafe, + isRetryableError, + retryWithDelay, + toBase64UrlSafe, +} from './utils'; +import Logger from '../../../util/Logger'; import { bytesToString } from '@metamask/utils'; import { toByteArray, fromByteArray } from 'react-native-quick-base64'; import QuickCrypto from 'react-native-quick-crypto'; @@ -47,6 +53,9 @@ export async function getAuthTokens( res.status }]: ${await res.text()}`, OAuthErrorType.AuthServerError, + { + status: res.status, + }, ); } @@ -92,14 +101,45 @@ export abstract class BaseLoginHandler { abstract getAuthTokenRequestData(params: HandleFlowParams): AuthRequestParams; /** - * Get the auth tokens from the auth server + * Get the auth tokens from the auth server with automatic retry logic. + * + * Retries are performed with exponential backoff and jitter for transient failures + * (network errors, 5xx server errors). Client errors (4xx) are not retried as they + * indicate issues with the request itself. * * @param params - The params from the login handler * @param authServerUrl - The url of the auth server + * @returns Promise resolving to the auth response */ getAuthTokens(params: HandleFlowParams, authServerUrl: string) { const requestData = this.getAuthTokenRequestData(params); - return getAuthTokens(requestData, this.authServerPath, authServerUrl); + const authConnection = this.authConnection; + + return retryWithDelay( + () => getAuthTokens(requestData, this.authServerPath, authServerUrl), + { + maxRetries: 3, + baseDelayMs: 500, + maxDelayMs: 5000, + jitterFactor: 0.25, + shouldRetry: isRetryableError, + onRetry: ({ attempt, willRetry, delayMs, error }) => { + const errorMessage = + error instanceof Error ? error.message : String(error); + if (willRetry) { + Logger.log( + `[OAuth:${authConnection}] getAuthTokens failed (attempt ${attempt + 1}/4), ` + + `retrying in ${delayMs}ms: ${errorMessage}`, + ); + } else { + Logger.log( + `[OAuth:${authConnection}] getAuthTokens failed (attempt ${attempt + 1}/4), ` + + `not retrying: ${errorMessage}`, + ); + } + }, + }, + ); } /** diff --git a/app/core/OAuthService/OAuthLoginHandlers/utils.test.ts b/app/core/OAuthService/OAuthLoginHandlers/utils.test.ts new file mode 100644 index 00000000000..a209b10153b --- /dev/null +++ b/app/core/OAuthService/OAuthLoginHandlers/utils.test.ts @@ -0,0 +1,461 @@ +import { retryWithDelay, isRetryableError, RetryContext } from './utils'; +import { OAuthError, OAuthErrorType } from '../error'; + +describe('isRetryableError', () => { + it('returns true for regular Error (only OAuthError status is checked)', () => { + expect(isRetryableError(new Error('Network request failed'))).toBe(true); + expect(isRetryableError(new Error('Request timeout'))).toBe(true); + expect(isRetryableError(new Error('status: 400'))).toBe(true); + expect(isRetryableError(new Error('status: 500'))).toBe(true); + }); + + it('returns false for OAuthError with 4xx status', () => { + expect( + isRetryableError( + new OAuthError('Bad Request', OAuthErrorType.AuthServerError, { + status: 400, + }), + ), + ).toBe(false); + expect( + isRetryableError( + new OAuthError('Unauthorized', OAuthErrorType.AuthServerError, { + status: 401, + }), + ), + ).toBe(false); + expect( + isRetryableError( + new OAuthError('Not Found', OAuthErrorType.AuthServerError, { + status: 404, + }), + ), + ).toBe(false); + }); + + it('returns true for OAuthError with 5xx status', () => { + const error = new OAuthError( + 'Server Error', + OAuthErrorType.AuthServerError, + { status: 500 }, + ); + + expect(isRetryableError(error)).toBe(true); + }); + + it('returns true for OAuthError without status in data', () => { + const error = new OAuthError( + 'Unknown error', + OAuthErrorType.AuthServerError, + ); + + expect(isRetryableError(error)).toBe(true); + }); + + it('returns true for non-Error objects', () => { + expect(isRetryableError('string error')).toBe(true); + expect(isRetryableError({ message: 'object error' })).toBe(true); + expect(isRetryableError(null)).toBe(true); + expect(isRetryableError(undefined)).toBe(true); + }); +}); + +describe('retryWithDelay', () => { + // Use minimal delays for fast tests + const fastRetryOptions = { + baseDelayMs: 1, + maxDelayMs: 10, + jitterFactor: 0, + }; + + describe('successful execution', () => { + it('returns immediately on first success', async () => { + const operation = jest.fn().mockResolvedValue('success'); + + const result = await retryWithDelay(operation, { + maxRetries: 3, + ...fastRetryOptions, + }); + + expect(result).toBe('success'); + expect(operation).toHaveBeenCalledTimes(1); + }); + + it('succeeds after retries when operation eventually succeeds', async () => { + const operation = jest + .fn() + .mockRejectedValueOnce(new Error('status: [500]')) + .mockRejectedValueOnce(new Error('status: [500]')) + .mockResolvedValue('success'); + + const result = await retryWithDelay(operation, { + maxRetries: 3, + ...fastRetryOptions, + }); + + expect(result).toBe('success'); + expect(operation).toHaveBeenCalledTimes(3); + }); + }); + + describe('failure handling', () => { + it('throws after exhausting all retries', async () => { + const error = new Error('Server Error'); + const operation = jest.fn().mockRejectedValue(error); + + await expect( + retryWithDelay(operation, { maxRetries: 2, ...fastRetryOptions }), + ).rejects.toThrow('Server Error'); + + expect(operation).toHaveBeenCalledTimes(3); // Initial + 2 retries + }); + + it('does not retry 4xx OAuthError by default', async () => { + const error = new OAuthError( + 'Bad Request', + OAuthErrorType.AuthServerError, + { status: 400 }, + ); + const operation = jest.fn().mockRejectedValue(error); + + await expect( + retryWithDelay(operation, { maxRetries: 3, ...fastRetryOptions }), + ).rejects.toThrow(); + + expect(operation).toHaveBeenCalledTimes(1); // No retries + }); + }); + + describe('custom shouldRetry', () => { + it('respects custom shouldRetry returning false', async () => { + const error = new Error('Custom error'); + const operation = jest.fn().mockRejectedValue(error); + const shouldRetry = jest.fn().mockReturnValue(false); + + await expect( + retryWithDelay(operation, { + maxRetries: 3, + shouldRetry, + ...fastRetryOptions, + }), + ).rejects.toThrow('Custom error'); + + expect(operation).toHaveBeenCalledTimes(1); + expect(shouldRetry).toHaveBeenCalledWith(error); + }); + + it('respects custom shouldRetry returning true', async () => { + const error = new Error('status: [400]: Should not retry by default'); + const operation = jest.fn().mockRejectedValue(error); + const shouldRetry = jest.fn().mockReturnValue(true); // Override default + + await expect( + retryWithDelay(operation, { + maxRetries: 2, + shouldRetry, + ...fastRetryOptions, + }), + ).rejects.toThrow(); + + expect(operation).toHaveBeenCalledTimes(3); // Initial + 2 retries + }); + }); + + describe('onRetry callback', () => { + it('calls onRetry for each retry attempt', async () => { + const error = new Error('status: [500]'); + const operation = jest.fn().mockRejectedValue(error); + const onRetry = jest.fn(); + + await expect( + retryWithDelay(operation, { + maxRetries: 2, + onRetry, + ...fastRetryOptions, + }), + ).rejects.toThrow(); + + // Called for each failed attempt + expect(onRetry).toHaveBeenCalledTimes(3); + + // First call - will retry + expect(onRetry).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + attempt: 0, + willRetry: true, + error, + }), + ); + + // Second call - will retry + expect(onRetry).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + attempt: 1, + willRetry: true, + error, + }), + ); + + // Third call - won't retry (last attempt) + expect(onRetry).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + attempt: 2, + willRetry: false, + error, + }), + ); + }); + + it('calls onRetry with willRetry=false for non-retryable errors', async () => { + const error = new OAuthError( + 'Bad Request', + OAuthErrorType.AuthServerError, + { status: 400 }, + ); + const operation = jest.fn().mockRejectedValue(error); + const onRetry = jest.fn(); + + await expect( + retryWithDelay(operation, { + maxRetries: 3, + onRetry, + ...fastRetryOptions, + }), + ).rejects.toThrow(); + + expect(onRetry).toHaveBeenCalledTimes(1); + expect(onRetry).toHaveBeenCalledWith( + expect.objectContaining({ + attempt: 0, + willRetry: false, + }), + ); + }); + + it('does not call onRetry on success', async () => { + const operation = jest.fn().mockResolvedValue('success'); + const onRetry = jest.fn(); + + const result = await retryWithDelay(operation, { + maxRetries: 3, + onRetry, + ...fastRetryOptions, + }); + + expect(result).toBe('success'); + expect(onRetry).not.toHaveBeenCalled(); + }); + }); + + describe('delay calculation', () => { + it('uses exponential backoff', async () => { + const error = new Error('status: [500]'); + const operation = jest.fn().mockRejectedValue(error); + const onRetry = jest.fn(); + + // Disable jitter for predictable delays + await expect( + retryWithDelay(operation, { + maxRetries: 3, + baseDelayMs: 10, + maxDelayMs: 1000, + jitterFactor: 0, // No jitter + onRetry, + }), + ).rejects.toThrow(); + + // Check delays: 10, 20, 40 (exponential) + const delays = onRetry.mock.calls + .filter((call: [RetryContext]) => call[0].willRetry) + .map((call: [RetryContext]) => call[0].delayMs); + + expect(delays[0]).toBe(10); // 10 * 2^0 + expect(delays[1]).toBe(20); // 10 * 2^1 + expect(delays[2]).toBe(40); // 10 * 2^2 + }); + + it('caps delay at maxDelayMs', async () => { + const error = new Error('status: [500]'); + const operation = jest.fn().mockRejectedValue(error); + const onRetry = jest.fn(); + + await expect( + retryWithDelay(operation, { + maxRetries: 5, + baseDelayMs: 10, + maxDelayMs: 30, + jitterFactor: 0, + onRetry, + }), + ).rejects.toThrow(); + + const delays = onRetry.mock.calls + .filter((call: [RetryContext]) => call[0].willRetry) + .map((call: [RetryContext]) => call[0].delayMs); + + // 10, 20, 30 (capped), 30 (capped), 30 (capped) + expect(delays[0]).toBe(10); + expect(delays[1]).toBe(20); + expect(delays[2]).toBe(30); + expect(delays[3]).toBe(30); + expect(delays[4]).toBe(30); + }); + + it('applies jitter to delays', async () => { + const error = new Error('status: [500]'); + const operation = jest.fn().mockRejectedValue(error); + const onRetry = jest.fn(); + + // Mock Math.random to return predictable values + const mockRandom = jest.spyOn(Math, 'random').mockReturnValue(0.5); + + await expect( + retryWithDelay(operation, { + maxRetries: 1, + baseDelayMs: 1000, + maxDelayMs: 10000, + jitterFactor: 0.3, + onRetry, + }), + ).rejects.toThrow(); + + // With jitterFactor=0.3 and random=0.5: + // delay = 1000 * (1 - 0.3 + 0.5 * 0.3) = 1000 * 0.85 = 850 + const delay = onRetry.mock.calls[0][0].delayMs; + expect(delay).toBe(850); + + mockRandom.mockRestore(); + }); + }); + + describe('callback error handling', () => { + it('preserves original error when shouldRetry callback throws', async () => { + const originalError = new Error('status: [401]: Unauthorized'); + const operation = jest.fn().mockRejectedValue(originalError); + const shouldRetry = jest.fn().mockImplementation(() => { + throw new Error('Cannot read property "message" of undefined'); + }); + + await expect( + retryWithDelay(operation, { + maxRetries: 3, + shouldRetry, + ...fastRetryOptions, + }), + ).rejects.toThrow('status: [401]: Unauthorized'); + + expect(operation).toHaveBeenCalledTimes(1); + }); + + it('preserves original error when onRetry callback throws', async () => { + const originalError = new OAuthError( + 'Bad Request', + OAuthErrorType.AuthServerError, + { status: 400 }, + ); + const operation = jest.fn().mockRejectedValue(originalError); + const onRetry = jest.fn().mockImplementation(() => { + throw new Error('Logging failed'); + }); + + await expect( + retryWithDelay(operation, { + maxRetries: 3, + onRetry, + ...fastRetryOptions, + }), + ).rejects.toThrow(); + + expect(operation).toHaveBeenCalledTimes(1); + }); + + it('does not retry when shouldRetry throws (fails safely)', async () => { + const originalError = new Error('status: [500]: Server Error'); + const operation = jest.fn().mockRejectedValue(originalError); + const shouldRetry = jest.fn().mockImplementation(() => { + throw new Error('shouldRetry crashed'); + }); + + await expect( + retryWithDelay(operation, { + maxRetries: 3, + shouldRetry, + ...fastRetryOptions, + }), + ).rejects.toThrow('status: [500]: Server Error'); + + // Should not retry because shouldRetry threw (defaults to false) + expect(operation).toHaveBeenCalledTimes(1); + }); + + it('continues retrying when onRetry throws on retryable errors', async () => { + const originalError = new Error('status: [500]: Server Error'); + const operation = jest + .fn() + .mockRejectedValueOnce(originalError) + .mockRejectedValueOnce(originalError) + .mockResolvedValue('success'); + const onRetry = jest.fn().mockImplementation(() => { + throw new Error('onRetry crashed'); + }); + + const result = await retryWithDelay(operation, { + maxRetries: 3, + onRetry, + ...fastRetryOptions, + }); + + expect(result).toBe('success'); + expect(operation).toHaveBeenCalledTimes(3); + expect(onRetry).toHaveBeenCalledTimes(2); + }); + }); + + describe('edge cases', () => { + it('throws if maxRetries is 0', async () => { + const error = new Error('status: [500]'); + const operation = jest.fn().mockRejectedValue(error); + + await expect( + retryWithDelay(operation, { maxRetries: 0, ...fastRetryOptions }), + ).rejects.toThrow(); + + expect(operation).toHaveBeenCalledTimes(1); + }); + + it('throws if maxRetries is negative', async () => { + const error = new Error('status: [500]'); + const operation = jest.fn().mockRejectedValue(error); + + await expect( + retryWithDelay(operation, { maxRetries: -5, ...fastRetryOptions }), + ).rejects.toThrow(); + + expect(operation).toHaveBeenCalledTimes(1); + }); + + it('uses default jitterFactor when not provided', async () => { + const error = new Error('status: [500]'); + const operation = jest.fn().mockRejectedValue(error); + const onRetry = jest.fn(); + + await expect( + retryWithDelay(operation, { + maxRetries: 1, + baseDelayMs: 1, + maxDelayMs: 10, + onRetry, + }), + ).rejects.toThrow(); + + // Verify delay was calculated with default jitter (0.3) + // With baseDelayMs=1, jitterFactor=0.3: delay should be in range [0.7, 1] + const delay = onRetry.mock.calls[0][0].delayMs; + expect(delay).toBeGreaterThan(0); + expect(delay).toBeLessThanOrEqual(10); + }); + }); +}); diff --git a/app/core/OAuthService/OAuthLoginHandlers/utils.ts b/app/core/OAuthService/OAuthLoginHandlers/utils.ts index 4eb06a1bce0..1720c6f8570 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/utils.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/utils.ts @@ -1,3 +1,5 @@ +import { OAuthError } from '../error'; + export function toBase64UrlSafe(base64String: string): string { return base64String .replace(/\+/g, '-') @@ -11,3 +13,177 @@ export function fromBase64UrlSafe(base64String: string): string { .replace(/_/g, '/') .padEnd(base64String.length + ((4 - (base64String.length % 4)) % 4), '='); } + +export interface RetryContext { + /** The error that caused the retry (0-indexed) */ + error: unknown; + /** The attempt number that just failed (0-indexed) */ + attempt: number; + /** Whether another retry will be attempted */ + willRetry: boolean; + /** The delay before the next retry (if willRetry is true) */ + delayMs: number; +} + +export interface RetryOptions { + /** Maximum number of retry attempts (not including the initial attempt) */ + maxRetries: number; + /** Base delay in milliseconds for exponential backoff */ + baseDelayMs?: number; + /** Maximum delay cap in milliseconds */ + maxDelayMs?: number; + /** Jitter factor (0-1) to randomize delays and prevent thundering herd */ + jitterFactor?: number; + /** Custom function to determine if an error should be retried */ + shouldRetry?: (error: unknown) => boolean; + /** Callback fired on each retry attempt (for logging/observability) */ + onRetry?: (context: RetryContext) => void; +} + +/** + * Calculates delay with exponential backoff and jitter. + * + * @param attempt - The current attempt number (0-indexed) + * @param baseDelayMs - Base delay in milliseconds + * @param maxDelayMs - Maximum delay cap + * @param jitterFactor - Jitter factor (0-1) + * @returns The calculated delay in milliseconds + */ +function calculateDelayWithJitter( + attempt: number, + baseDelayMs: number, + maxDelayMs: number, + jitterFactor: number, +): number { + // Exponential backoff: baseDelay * 2^attempt + const exponentialDelay = baseDelayMs * Math.pow(2, attempt); + // Cap the delay + const cappedDelay = Math.min(exponentialDelay, maxDelayMs); + // Apply jitter: delay * (1 - jitterFactor + random * jitterFactor) + // This gives a range of [delay * (1 - jitterFactor), delay] + const jitteredDelay = + cappedDelay * (1 - jitterFactor + Math.random() * jitterFactor); + return Math.round(jitteredDelay); +} + +/** + * Checks if an error message indicates a client error (4xx) that should not be retried. + * Client errors indicate issues with the request itself, not transient server issues. + * + * @param error - The error to check + * @returns true if the error should be retried, false otherwise + */ +export function isRetryableError(error: unknown): boolean { + if (!(error instanceof Error)) { + return true; // Unknown error types are retried by default + } + + if (error instanceof OAuthError) { + const status = String(error.data.status); + // Check for HTTP 4xx client errors in the message + // These are not retryable as the request itself is invalid + const clientErrorPattern = /4\d{2}/i; + if (clientErrorPattern.test(status)) { + return false; + } + } + + // Network errors and 5xx server errors are retryable + return true; +} + +/** + * Retries an async operation with exponential backoff, jitter, and smart error classification. + * + * Features: + * - Exponential backoff with configurable base delay + * - Jitter to prevent thundering herd problem + * - Smart error classification (doesn't retry 4xx client errors by default) + * - Observability via onRetry callback + * + * @param operation - Async function to execute on each attempt + * @param options - Retry configuration + * @returns The resolved value of the operation + * @throws The last error if all retries are exhausted or if error is non-retryable + * + * @example + * ```ts + * const result = await retryWithDelay( + * () => fetchAuthTokens(), + * { + * maxRetries: 3, + * baseDelayMs: 1000, + * onRetry: ({ attempt, error, delayMs }) => { + * Logger.log(`Retry ${attempt + 1} after ${delayMs}ms: ${error.message}`); + * }, + * }, + * ); + * ``` + */ +export async function retryWithDelay( + operation: () => Promise, + options: RetryOptions, +): Promise { + const { + maxRetries, + baseDelayMs = 500, + maxDelayMs = 5000, + jitterFactor = 0.25, + shouldRetry = isRetryableError, + onRetry, + } = options; + + const normalizedMaxRetries = Math.max(0, maxRetries); + + for (let attempt = 0; attempt <= normalizedMaxRetries; attempt++) { + try { + return await operation(); + } catch (error) { + const isLastAttempt = attempt === normalizedMaxRetries; + + // Wrap shouldRetry in try-catch to prevent callback errors from + // masking the original operation error. Default to not retrying + // if the callback fails (safer to fail fast than mask errors). + let isErrorRetryable: boolean; + try { + isErrorRetryable = shouldRetry(error); + } catch { + isErrorRetryable = false; + } + + const willRetry = !isLastAttempt && isErrorRetryable; + + const delayMs = willRetry + ? calculateDelayWithJitter( + attempt, + baseDelayMs, + maxDelayMs, + jitterFactor, + ) + : 0; + + // Wrap onRetry in try-catch to prevent logging/observability errors + // from masking the original operation error + try { + onRetry?.({ error, attempt, willRetry, delayMs }); + } catch { + // Silently ignore callback errors to preserve the original error + } + + if (!willRetry) { + throw error; + } + + // Wait before the next retry + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + } + + // This is technically unreachable because: + // - If operation succeeds, we return + // - If operation fails on last attempt or non-retryable error, we throw + // But TypeScript needs this for type safety + throw new Error('Retry attempts exhausted'); +} diff --git a/app/core/OAuthService/error.ts b/app/core/OAuthService/error.ts index aeccb385463..ce39684f1b1 100644 --- a/app/core/OAuthService/error.ts +++ b/app/core/OAuthService/error.ts @@ -40,8 +40,13 @@ export const OAuthErrorMessages: Record = { export class OAuthError extends Error { public readonly code: OAuthErrorType; + public readonly data: Record; - constructor(errMessage: string | Error, code: OAuthErrorType) { + constructor( + errMessage: string | Error, + code: OAuthErrorType, + data?: Record, + ) { if (errMessage instanceof Error) { super(errMessage.message); this.stack = errMessage.stack; @@ -51,5 +56,6 @@ export class OAuthError extends Error { } this.message = `${OAuthErrorMessages[code]} - ${errMessage}`; this.code = code; + this.data = data || {}; } } diff --git a/app/images/rewards/metamask-rewards-points-alternative.svg b/app/images/rewards/metamask-rewards-points-alternative.svg index 936341681cb..662ff3580e5 100644 --- a/app/images/rewards/metamask-rewards-points-alternative.svg +++ b/app/images/rewards/metamask-rewards-points-alternative.svg @@ -1,3 +1,3 @@ - - + + diff --git a/app/selectors/rampsController/index.test.ts b/app/selectors/rampsController/index.test.ts index 3b443acbf61..4ba6edc6125 100644 --- a/app/selectors/rampsController/index.test.ts +++ b/app/selectors/rampsController/index.test.ts @@ -5,16 +5,22 @@ import { UserRegion, type Provider, type Country, + type PaymentMethod, } from '@metamask/ramps-controller'; import { selectUserRegion, selectUserRegionRequest, - selectPreferredProvider, + selectSelectedProvider, selectProviders, selectTokens, + selectSelectedToken, + selectCountries, selectCountriesRequest, selectTokensRequest, selectProvidersRequest, + selectPaymentMethods, + selectSelectedPaymentMethod, + selectPaymentMethodsRequest, selectRampsControllerState, } from './index'; @@ -44,7 +50,7 @@ const mockUserRegion: UserRegion = { template: 'XXX-XXX-XXXX', }, currency: 'USD', - supported: true, + supported: { buy: true, sell: true }, }, state: { stateId: 'CA', name: 'California' }, regionCode: 'us-ca', @@ -76,35 +82,35 @@ const mockCountries: Country[] = [ template: 'XXX-XXX-XXXX', }, currency: 'USD', - supported: true, + supported: { buy: true, sell: true }, }, ]; +const mockToken = { + assetId: 'eip155:1/erc20:0x0000000000000000000000000000000000000000', + chainId: 'eip155:1', + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + iconUrl: 'https://example.com/eth-icon.png', + tokenSupported: true, +}; + const mockTokens = { - topTokens: [ - { - assetId: 'eip155:1/erc20:0x0000000000000000000000000000000000000000', - chainId: 'eip155:1', - name: 'Ethereum', - symbol: 'ETH', - decimals: 18, - iconUrl: 'https://example.com/eth-icon.png', - tokenSupported: true, - }, - ], - allTokens: [ - { - assetId: 'eip155:1/erc20:0x0000000000000000000000000000000000000000', - chainId: 'eip155:1', - name: 'Ethereum', - symbol: 'ETH', - decimals: 18, - iconUrl: 'https://example.com/eth-icon.png', - tokenSupported: true, - }, - ], + topTokens: [mockToken], + allTokens: [mockToken], }; +const mockPaymentMethod: PaymentMethod = { + id: '/payments/debit-credit-card', + paymentType: 'debit-credit-card', + name: 'Debit/Credit Card', + score: 100, + icon: 'card', +}; + +const mockPaymentMethods: PaymentMethod[] = [mockPaymentMethod]; + describe('RampsController Selectors', () => { describe('selectUserRegion', () => { it('returns user region from state', () => { @@ -124,7 +130,7 @@ describe('RampsController Selectors', () => { it('returns request state with data, isFetching, and error', () => { const state = createMockState({ requests: { - 'updateUserRegion:[]': { + 'init:[]': { status: RequestStatus.SUCCESS, data: mockUserRegion, error: null, @@ -146,7 +152,7 @@ describe('RampsController Selectors', () => { it('returns isFetching true when request is loading', () => { const state = createMockState({ requests: { - 'updateUserRegion:[]': { + 'init:[]': { status: RequestStatus.LOADING, data: null, error: null, @@ -164,7 +170,7 @@ describe('RampsController Selectors', () => { it('returns error when request failed', () => { const state = createMockState({ requests: { - 'updateUserRegion:[]': { + 'init:[]': { status: RequestStatus.ERROR, data: null, error: 'Network error', @@ -192,17 +198,17 @@ describe('RampsController Selectors', () => { }); }); - describe('selectPreferredProvider', () => { - it('returns preferred provider from state', () => { - const state = createMockState({ preferredProvider: mockProvider }); + describe('selectSelectedProvider', () => { + it('returns selected provider from state', () => { + const state = createMockState({ selectedProvider: mockProvider }); - expect(selectPreferredProvider(state)).toEqual(mockProvider); + expect(selectSelectedProvider(state)).toEqual(mockProvider); }); - it('returns null when preferred provider is null', () => { - const state = createMockState({ preferredProvider: null }); + it('returns null when selected provider is null', () => { + const state = createMockState({ selectedProvider: null }); - expect(selectPreferredProvider(state)).toBeNull(); + expect(selectSelectedProvider(state)).toBeNull(); }); it('returns null when RampsController state is undefined', () => { @@ -214,7 +220,7 @@ describe('RampsController Selectors', () => { }, } as unknown as RootState; - expect(selectPreferredProvider(state)).toBeNull(); + expect(selectSelectedProvider(state)).toBeNull(); }); }); @@ -258,11 +264,51 @@ describe('RampsController Selectors', () => { }); }); + describe('selectSelectedToken', () => { + it('returns selected token from state', () => { + const state = createMockState({ selectedToken: mockToken }); + + expect(selectSelectedToken(state)).toEqual(mockToken); + }); + + it('returns null when selected token is null', () => { + const state = createMockState({ selectedToken: null }); + + expect(selectSelectedToken(state)).toBeNull(); + }); + + it('returns null when RampsController state is undefined', () => { + const state = { + engine: { + backgroundState: { + RampsController: undefined, + }, + }, + } as unknown as RootState; + + expect(selectSelectedToken(state)).toBeNull(); + }); + }); + + describe('selectCountries', () => { + it('returns countries from state', () => { + const state = createMockState({ countries: mockCountries }); + + expect(selectCountries(state)).toEqual(mockCountries); + }); + + it('returns empty array when countries are not available', () => { + const state = createMockState(); + + expect(selectCountries(state)).toEqual([]); + }); + }); + describe('selectCountriesRequest', () => { - it('returns request state for buy action', () => { + it('returns request state', () => { const state = createMockState({ requests: { - 'getCountries:["buy"]': { + 'getCountries:[]': { status: RequestStatus.SUCCESS, data: mockCountries, error: null, @@ -272,7 +318,7 @@ describe('RampsController Selectors', () => { }, }); - const result = selectCountriesRequest('buy')(state); + const result = selectCountriesRequest(state); expect(result).toEqual({ data: mockCountries, @@ -281,12 +327,12 @@ describe('RampsController Selectors', () => { }); }); - it('returns request state for sell action', () => { + it('returns loading state when request is in progress', () => { const state = createMockState({ requests: { - 'getCountries:["sell"]': { - status: RequestStatus.SUCCESS, - data: mockCountries, + 'getCountries:[]': { + status: RequestStatus.LOADING, + data: null, error: null, timestamp: Date.now(), lastFetchedAt: Date.now(), @@ -294,37 +340,41 @@ describe('RampsController Selectors', () => { }, }); - const result = selectCountriesRequest('sell')(state); + const result = selectCountriesRequest(state); expect(result).toEqual({ - data: mockCountries, - isFetching: false, + data: null, + isFetching: true, error: null, }); }); - it('defaults to buy action when not provided', () => { + it('returns error state when request fails', () => { const state = createMockState({ requests: { - 'getCountries:["buy"]': { - status: RequestStatus.SUCCESS, - data: mockCountries, - error: null, + 'getCountries:[]': { + status: RequestStatus.ERROR, + data: null, + error: 'Network error', timestamp: Date.now(), lastFetchedAt: Date.now(), }, }, }); - const result = selectCountriesRequest()(state); + const result = selectCountriesRequest(state); - expect(result.data).toEqual(mockCountries); + expect(result).toEqual({ + data: null, + isFetching: false, + error: 'Network error', + }); }); it('returns default state when request does not exist', () => { const state = createMockState(); - const result = selectCountriesRequest('buy')(state); + const result = selectCountriesRequest(state); expect(result).toEqual({ data: null, @@ -507,11 +557,124 @@ describe('RampsController Selectors', () => { }); }); + describe('selectPaymentMethods', () => { + it('returns payment methods from state', () => { + const state = createMockState({ paymentMethods: mockPaymentMethods }); + + expect(selectPaymentMethods(state)).toEqual(mockPaymentMethods); + }); + + it('returns empty array when payment methods are not available', () => { + const state = createMockState(); + + expect(selectPaymentMethods(state)).toEqual([]); + }); + }); + + describe('selectSelectedPaymentMethod', () => { + it('returns selected payment method from state', () => { + const state = createMockState({ + selectedPaymentMethod: mockPaymentMethod, + }); + + expect(selectSelectedPaymentMethod(state)).toEqual(mockPaymentMethod); + }); + + it('returns null when selected payment method is null', () => { + const state = createMockState({ selectedPaymentMethod: null }); + + expect(selectSelectedPaymentMethod(state)).toBeNull(); + }); + + it('returns null when RampsController state is undefined', () => { + const state = { + engine: { + backgroundState: { + RampsController: undefined, + }, + }, + } as unknown as RootState; + + expect(selectSelectedPaymentMethod(state)).toBeNull(); + }); + }); + + describe('selectPaymentMethodsRequest', () => { + it('returns request state for region, fiat, assetId, and provider', () => { + const state = createMockState({ + requests: { + 'getPaymentMethods:["us-ca","usd","eip155:1/erc20:0x123","provider-1"]': + { + status: RequestStatus.SUCCESS, + data: { payments: mockPaymentMethods }, + error: null, + timestamp: Date.now(), + lastFetchedAt: Date.now(), + }, + }, + }); + + const result = selectPaymentMethodsRequest( + 'us-ca', + 'usd', + 'eip155:1/erc20:0x123', + 'provider-1', + )(state); + + expect(result).toEqual({ + data: { payments: mockPaymentMethods }, + isFetching: false, + error: null, + }); + }); + + it('normalizes region and fiat to lowercase and trims', () => { + const state = createMockState({ + requests: { + 'getPaymentMethods:["us-ca","usd","eip155:1/erc20:0x123","provider-1"]': + { + status: RequestStatus.SUCCESS, + data: { payments: mockPaymentMethods }, + error: null, + timestamp: Date.now(), + lastFetchedAt: Date.now(), + }, + }, + }); + + const result = selectPaymentMethodsRequest( + ' US-CA ', + ' USD ', + 'eip155:1/erc20:0x123', + 'provider-1', + )(state); + + expect(result.data).toEqual({ payments: mockPaymentMethods }); + }); + + it('returns default state when request does not exist', () => { + const state = createMockState(); + + const result = selectPaymentMethodsRequest( + 'us-ca', + 'usd', + 'eip155:1/erc20:0x123', + 'provider-1', + )(state); + + expect(result).toEqual({ + data: null, + isFetching: false, + error: null, + }); + }); + }); + describe('selectRampsControllerState', () => { it('returns RampsController state', () => { const rampsState: Partial = { userRegion: mockUserRegion, - preferredProvider: mockProvider, + selectedProvider: mockProvider, providers: [mockProvider], tokens: mockTokens, requests: {}, diff --git a/app/selectors/rampsController/index.ts b/app/selectors/rampsController/index.ts index ef81b290971..0f8f6e6deeb 100644 --- a/app/selectors/rampsController/index.ts +++ b/app/selectors/rampsController/index.ts @@ -4,6 +4,7 @@ import { type UserRegion, type Provider, type Country, + type PaymentMethodsResponse, type RampsControllerState, } from '@metamask/ramps-controller'; import { RootState } from '../../reducers'; @@ -28,11 +29,11 @@ export const selectUserRegion = createSelector( ); /** - * Selects the user's preferred provider from state. + * Selects the user's selected provider from state. */ -export const selectPreferredProvider = createSelector( +export const selectSelectedProvider = createSelector( selectRampsControllerState, - (rampsControllerState) => rampsControllerState?.preferredProvider ?? null, + (rampsControllerState) => rampsControllerState?.selectedProvider ?? null, ); /** @@ -51,26 +52,55 @@ export const selectTokens = createSelector( (rampsControllerState) => rampsControllerState?.tokens ?? null, ); +/** + * Selects the user's selected token from state. + */ +export const selectSelectedToken = createSelector( + selectRampsControllerState, + (rampsControllerState) => rampsControllerState?.selectedToken ?? null, +); + +/** + * Selects the list of countries available for ramp actions. + */ +export const selectCountries = createSelector( + selectRampsControllerState, + (rampsControllerState) => rampsControllerState?.countries ?? [], +); + +/** + * Selects the payment methods available for the current context. + */ +export const selectPaymentMethods = createSelector( + selectRampsControllerState, + (rampsControllerState) => rampsControllerState?.paymentMethods ?? [], +); + +/** + * Selects the user's selected payment method from state. + */ +export const selectSelectedPaymentMethod = createSelector( + selectRampsControllerState, + (rampsControllerState) => rampsControllerState?.selectedPaymentMethod ?? null, +); + /** * Selects the user region request state. */ export const selectUserRegionRequest = createRequestSelector< RootState, UserRegion | null ->(selectRampsControllerState, 'updateUserRegion', []); +>(selectRampsControllerState, 'init', []); /** - * Selects the countries request state for a given action. + * Selects the countries request state. * - * @param action - The ramp action type ('buy' or 'sell'). * @returns Request selector for countries. */ -export const selectCountriesRequest = (action: 'buy' | 'sell' = 'buy') => - createRequestSelector( - selectRampsControllerState, - 'getCountries', - [action], - ); +export const selectCountriesRequest = createRequestSelector< + RootState, + Country[] +>(selectRampsControllerState, 'getCountries', []); /** * Selects the tokens request state for a given region and action. @@ -116,3 +146,24 @@ export const selectProvidersRequest = ( options?.payments, ], ); + +/** + * Selects the payment methods request state for a given context. + * + * @param region - The region code (e.g., "us", "fr", "us-ny"). + * @param fiat - The fiat currency code (e.g., "usd", "eur"). + * @param assetId - The asset ID in CAIP-19 format. + * @param provider - The provider ID. + * @returns Request selector for payment methods. + */ +export const selectPaymentMethodsRequest = ( + region: string, + fiat: string, + assetId: string, + provider: string, +) => + createRequestSelector( + selectRampsControllerState, + 'getPaymentMethods', + [region.toLowerCase().trim(), fiat.toLowerCase().trim(), assetId, provider], + ); diff --git a/app/store/migrations/115.test.ts b/app/store/migrations/115.test.ts new file mode 100644 index 00000000000..22a8c6d45d8 --- /dev/null +++ b/app/store/migrations/115.test.ts @@ -0,0 +1,150 @@ +import migrate, { migrationVersion } from './115'; +import { captureException } from '@sentry/react-native'; +import { hasProperty } from '@metamask/utils'; + +jest.mock('@sentry/react-native', () => ({ + captureException: jest.fn(), +})); + +jest.mock('@metamask/utils', () => ({ + ...jest.requireActual('@metamask/utils'), + hasProperty: jest.fn(), +})); + +const mockHasProperty = hasProperty as jest.MockedFunction; + +const mockCaptureException = captureException as jest.MockedFunction< + typeof captureException +>; + +describe(`Migration ${migrationVersion}`, () => { + beforeEach(() => { + jest.clearAllMocks(); + // Default implementation: use the actual hasProperty function + mockHasProperty.mockImplementation( + jest.requireActual('@metamask/utils').hasProperty, + ); + }); + + it('returns state unchanged if state is invalid', () => { + const invalidState = null; + const result = migrate(invalidState); + expect(result).toBe(invalidState); + }); + + it('returns state unchanged if engine is missing', () => { + const invalidState = { foo: 'bar' }; + const result = migrate(invalidState); + expect(result).toStrictEqual(invalidState); + }); + + it('returns state unchanged if backgroundState is missing', () => { + const state = { engine: {} }; + const result = migrate(state); + expect(result).toStrictEqual(state); + }); + + it('returns state unchanged and captures exception if TokenListController is missing', () => { + const state = { + engine: { + backgroundState: {}, + }, + }; + const result = migrate(state); + expect(result).toStrictEqual(state); + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('missing TokenListController'), + }), + ); + }); + + it('returns state unchanged and captures exception if TokenListController is not an object', () => { + const state = { + engine: { + backgroundState: { + TokenListController: 'invalid-string', + }, + }, + }; + const result = migrate(state); + expect(result).toStrictEqual(state); + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining( + "Invalid TokenListController state: 'string'", + ), + }), + ); + }); + + it('removes preventPollingOnNetworkRestart from TokenListController', () => { + const state = { + engine: { + backgroundState: { + TokenListController: { + tokensChainsCache: {}, + preventPollingOnNetworkRestart: true, + }, + }, + }, + }; + + const result = migrate(state) as typeof state; + + expect(result.engine.backgroundState.TokenListController).toStrictEqual({ + tokensChainsCache: {}, + }); + expect( + result.engine.backgroundState.TokenListController, + ).not.toHaveProperty('preventPollingOnNetworkRestart'); + }); + + it('handles state without preventPollingOnNetworkRestart gracefully', () => { + const state = { + engine: { + backgroundState: { + TokenListController: { + tokensChainsCache: {}, + }, + }, + }, + }; + + const result = migrate(state); + + expect(result).toStrictEqual(state); + }); + + it('captures exception on error and returns state unchanged', () => { + const state = { + engine: { + backgroundState: { + TokenListController: {}, + }, + }, + }; + + const actualHasProperty = jest.requireActual('@metamask/utils').hasProperty; + + // Allow the first two calls (in ensureValidState) to succeed, + // then throw on the third call (first call in the try block) + mockHasProperty + .mockImplementationOnce(actualHasProperty) // ensureValidState: check for 'engine' + .mockImplementationOnce(actualHasProperty) // ensureValidState: check for 'backgroundState' + .mockImplementationOnce(() => { + throw new Error('Test error'); + }); + + const result = migrate(state); + + // State should be returned unchanged (migration catches the error) + expect(result).toStrictEqual(state); + // Verify the error was captured + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Migration 115 failed'), + }), + ); + }); +}); diff --git a/app/store/migrations/115.ts b/app/store/migrations/115.ts new file mode 100644 index 00000000000..8d5719edbd8 --- /dev/null +++ b/app/store/migrations/115.ts @@ -0,0 +1,54 @@ +import { hasProperty, isObject } from '@metamask/utils'; +import { ensureValidState } from './util'; +import { captureException } from '@sentry/react-native'; + +export const migrationVersion = 115; + +/** + * Migration 115: Remove preventPollingOnNetworkRestart from TokenListController state + * + * This migration removes the deprecated `preventPollingOnNetworkRestart` property + * from TokenListController state. This property was removed in @metamask/assets-controllers v99.0.0. + * + * @param state - The persisted Redux state + * @returns The migrated Redux state + */ +export default function migrate(state: unknown): unknown { + if (!ensureValidState(state, migrationVersion)) { + return state; + } + + try { + if (!hasProperty(state.engine.backgroundState, 'TokenListController')) { + captureException( + new Error( + `Migration ${migrationVersion}: Invalid TokenListController state: missing TokenListController`, + ), + ); + return state; + } + + const tokenListController = + state.engine.backgroundState.TokenListController; + + if (!isObject(tokenListController)) { + captureException( + new Error( + `Migration ${migrationVersion}: Invalid TokenListController state: '${typeof tokenListController}'`, + ), + ); + return state; + } + + if (hasProperty(tokenListController, 'preventPollingOnNetworkRestart')) { + delete tokenListController.preventPollingOnNetworkRestart; + } + + return state; + } catch (error) { + captureException( + new Error(`Migration ${migrationVersion} failed: ${error}`), + ); + return state; + } +} diff --git a/app/store/migrations/index.ts b/app/store/migrations/index.ts index ea9de4e9737..eb88845b357 100644 --- a/app/store/migrations/index.ts +++ b/app/store/migrations/index.ts @@ -115,6 +115,7 @@ import migration111 from './111'; import migration112 from './112'; import migration113 from './113'; import migration114 from './114'; +import migration115 from './115'; // Add migrations above this line import { ControllerStorage } from '../persistConfig'; @@ -249,6 +250,7 @@ export const migrationList: MigrationsList = { 112: migration112, 113: migration113, 114: migration114, + 115: migration115, }; // Enable both synchronous and asynchronous migrations diff --git a/app/util/errorUtils.test.ts b/app/util/errorUtils.test.ts index 30b92bcd0a9..c345ac41aa1 100644 --- a/app/util/errorUtils.test.ts +++ b/app/util/errorUtils.test.ts @@ -1,66 +1,102 @@ import { ensureError } from './errorUtils'; describe('ensureError', () => { - it('should return the same Error instance when passed an Error', () => { + it('returns the same Error instance when passed an Error', () => { const originalError = new Error('Test error'); + const result = ensureError(originalError); expect(result).toBe(originalError); expect(result.message).toBe('Test error'); }); - it('should convert string to Error with the string as message', () => { + it('converts string to Error with the string as message', () => { const result = ensureError('String error message'); expect(result).toBeInstanceOf(Error); expect(result.message).toBe('String error message'); }); - it('should convert number to Error with number as string message', () => { + it('converts number to Error with number as string message', () => { const result = ensureError(42); expect(result).toBeInstanceOf(Error); expect(result.message).toBe('42'); }); - it('should convert null to Error with "null" as message', () => { + it('converts null to Error with descriptive message', () => { const result = ensureError(null); expect(result).toBeInstanceOf(Error); - expect(result.message).toBe('null'); + expect(result.message).toBe('Unknown error (no details provided)'); }); - it('should convert undefined to Error with "undefined" as message', () => { + it('converts undefined to Error with descriptive message', () => { const result = ensureError(undefined); expect(result).toBeInstanceOf(Error); - expect(result.message).toBe('undefined'); + expect(result.message).toBe('Unknown error (no details provided)'); + }); + + it('includes context in message when provided with undefined', () => { + const result = ensureError(undefined, 'PerpsConnectionProvider.connect'); + + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe( + 'Unknown error (no details provided) [PerpsConnectionProvider.connect]', + ); }); - it('should convert object to Error with stringified object as message', () => { + it('includes context in message when provided with null', () => { + const result = ensureError(null, 'PerpsStreamManager.prewarm'); + + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe( + 'Unknown error (no details provided) [PerpsStreamManager.prewarm]', + ); + }); + + it('does not modify Error instance when context is provided', () => { + const originalError = new Error('Original error'); + + const result = ensureError(originalError, 'SomeContext'); + + expect(result).toBe(originalError); + expect(result.message).toBe('Original error'); + }); + + it('does not include context for string errors', () => { + const result = ensureError('String error', 'SomeContext'); + + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe('String error'); + }); + + it('converts object to Error with stringified object as message', () => { const obj = { code: 'ERROR_CODE', details: 'Some details' }; + const result = ensureError(obj); expect(result).toBeInstanceOf(Error); expect(result.message).toBe('[object Object]'); }); - it('should convert boolean to Error with string representation', () => { + it('converts boolean to Error with string representation', () => { const result = ensureError(false); expect(result).toBeInstanceOf(Error); expect(result.message).toBe('false'); }); - it('should preserve Error subclasses', () => { + it('preserves Error subclasses', () => { class CustomError extends Error { constructor(message: string) { super(message); this.name = 'CustomError'; } } - const customError = new CustomError('Custom error message'); + const result = ensureError(customError); expect(result).toBe(customError); diff --git a/app/util/errorUtils.ts b/app/util/errorUtils.ts index 46506c737f5..d2e9a6025ea 100644 --- a/app/util/errorUtils.ts +++ b/app/util/errorUtils.ts @@ -6,12 +6,20 @@ /** * Ensures we have a proper Error object for logging. * Converts unknown/string errors to proper Error instances. + * Handles undefined/null specially for better Sentry context. * @param error - The caught error (could be Error, string, or unknown) + * @param context - Optional context string to help identify the source of the error * @returns A proper Error instance */ -export function ensureError(error: unknown): Error { +export function ensureError(error: unknown, context?: string): Error { if (error instanceof Error) { return error; } + // Handle undefined/null specifically for better error context + // e.g. Hyperliquid SDK may reject with undefined when AbortSignal.reason is not set + if (error === undefined || error === null) { + const baseMessage = 'Unknown error (no details provided)'; + return new Error(context ? `${baseMessage} [${context}]` : baseMessage); + } return new Error(String(error)); } diff --git a/app/util/logs/__snapshots__/index.test.ts.snap b/app/util/logs/__snapshots__/index.test.ts.snap index 7a78989b518..f8c63f2f89a 100644 --- a/app/util/logs/__snapshots__/index.test.ts.snap +++ b/app/util/logs/__snapshots__/index.test.ts.snap @@ -587,11 +587,13 @@ exports[`logs :: generateStateLogs Sanitized SeedlessOnboardingController State "syncQueue": {}, }, "RampsController": { + "countries": [], "paymentMethods": [], - "preferredProvider": null, "providers": [], "requests": {}, "selectedPaymentMethod": null, + "selectedProvider": null, + "selectedToken": null, "tokens": null, "userRegion": null, }, @@ -605,6 +607,7 @@ exports[`logs :: generateStateLogs Sanitized SeedlessOnboardingController State "accounts": {}, "activeAccount": null, "activeBoosts": {}, + "pointsEstimateHistory": [], "pointsEvents": {}, "seasonStatuses": {}, "seasons": {}, @@ -759,7 +762,6 @@ exports[`logs :: generateStateLogs Sanitized SeedlessOnboardingController State "tokenBalances": {}, }, "TokenListController": { - "preventPollingOnNetworkRestart": false, "tokensChainsCache": {}, }, "TokenRatesController": { @@ -1379,11 +1381,13 @@ exports[`logs :: generateStateLogs generates a valid json export 1`] = ` "syncQueue": {}, }, "RampsController": { + "countries": [], "paymentMethods": [], - "preferredProvider": null, "providers": [], "requests": {}, "selectedPaymentMethod": null, + "selectedProvider": null, + "selectedToken": null, "tokens": null, "userRegion": null, }, @@ -1397,6 +1401,7 @@ exports[`logs :: generateStateLogs generates a valid json export 1`] = ` "accounts": {}, "activeAccount": null, "activeBoosts": {}, + "pointsEstimateHistory": [], "pointsEvents": {}, "seasonStatuses": {}, "seasons": {}, @@ -1534,7 +1539,6 @@ exports[`logs :: generateStateLogs generates a valid json export 1`] = ` "tokenBalances": {}, }, "TokenListController": { - "preventPollingOnNetworkRestart": false, "tokensChainsCache": {}, }, "TokenRatesController": { diff --git a/app/util/sentry/utils.ts b/app/util/sentry/utils.ts index 5bcee97dbb9..4737da72939 100644 --- a/app/util/sentry/utils.ts +++ b/app/util/sentry/utils.ts @@ -184,7 +184,6 @@ export const sentryStateMask = { }, }, TokenListController: { - preventPollingOnNetworkRestart: true, tokensChainsCache: { [AllProperties]: false, }, diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json index 60746f2f9b8..5d31e9c88da 100644 --- a/app/util/test/initial-background-state.json +++ b/app/util/test/initial-background-state.json @@ -49,8 +49,7 @@ "allDetectedTokens": {} }, "TokenListController": { - "tokensChainsCache": {}, - "preventPollingOnNetworkRestart": false + "tokensChainsCache": {} }, "CurrencyRateController": { "currentCurrency": "usd", @@ -734,6 +733,7 @@ "accounts": {}, "activeAccount": null, "activeBoosts": {}, + "pointsEstimateHistory": [], "pointsEvents": {}, "unlockedRewards": {}, "seasonStatuses": {}, @@ -752,11 +752,13 @@ "accountMeta": {} }, "RampsController": { + "countries": [], "paymentMethods": [], "userRegion": null, - "preferredProvider": null, + "selectedProvider": null, "providers": [], "selectedPaymentMethod": null, + "selectedToken": null, "tokens": null, "requests": {} } diff --git a/bitrise.yml b/bitrise.yml index 57bfbd3c1e1..46289201e5a 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -356,8 +356,8 @@ stages: - run_network_expansion_swimlane_android_smoke: {} - run_wallet_platform_swimlane_ios_smoke: {} - run_wallet_platform_swimlane_android_smoke: {} - - run_tag_smoke_confirmations_redesigned_android: {} - - run_tag_smoke_confirmations_redesigned_ios: {} + - run_tag_smoke_confirmations_android: {} + - run_tag_smoke_confirmations_ios: {} - run_tag_flask_build_tests_ios: {} - run_tag_flask_build_tests_android: {} - run_tag_smoke_accounts_ios: {} @@ -380,7 +380,7 @@ stages: # - run_tag_smoke_confirmations_android: {} - run_tag_smoke_identity_ios: {} # - run_tag_smoke_identity_android: {} - - run_tag_smoke_confirmations_redesigned_ios: {} + - run_tag_smoke_confirmations_ios: {} - run_tag_smoke_multichain_api_ios: {} - run_tag_smoke_accounts_ios: {} # - run_tag_smoke_accounts_android: {} @@ -448,8 +448,8 @@ stages: - run_network_abstraction_swimlane_android_smoke: {} - run_wallet_platform_swimlane_ios_smoke: {} - run_wallet_platform_swimlane_android_smoke: {} - - run_tag_smoke_confirmations_redesigned_android: {} - - run_tag_smoke_confirmations_redesigned_ios: {} + - run_tag_smoke_confirmations_android: {} + - run_tag_smoke_confirmations_ios: {} - run_tag_smoke_multichain_api_ios: {} - run_tag_smoke_accounts_ios: {} - run_tag_smoke_accounts_android: {} @@ -1161,18 +1161,18 @@ workflows: run_ios_api_specs: after_run: - ios_api_specs - run_tag_smoke_confirmations_redesigned_android: + run_tag_smoke_confirmations_android: meta: bitrise.io: stack: linux-docker-android-22.04 machine_type_id: elite-xl envs: - - TEST_SUITE_TAG: 'SmokeConfirmationsRedesigned ' + - TEST_SUITE_TAG: 'SmokeConfirmations' after_run: - android_e2e_test - run_tag_smoke_confirmations_redesigned_ios: + run_tag_smoke_confirmations_ios: envs: - - TEST_SUITE_TAG: 'SmokeConfirmationsRedesigned' + - TEST_SUITE_TAG: 'SmokeConfirmations' after_run: - ios_e2e_test run_tag_smoke_performance_ios: diff --git a/e2e/pages/Rewards/RewardsActivityTabView.ts b/e2e/pages/Rewards/RewardsActivityTabView.ts deleted file mode 100644 index 0af18d2b2f8..00000000000 --- a/e2e/pages/Rewards/RewardsActivityTabView.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { REWARDS_VIEW_SELECTORS } from '../../../app/components/UI/Rewards/Views/RewardsView.constants'; -import Matchers from '../../../tests/framework/Matchers'; - -class RewardsActivityTabView { - // FlatList container - get flatList() { - return Matchers.getElementByID(REWARDS_VIEW_SELECTORS.FLATLIST); - } - - // Activity event rows - getActivityEventRow(eventType: string, index: number) { - return Matchers.getElementByID( - `${REWARDS_VIEW_SELECTORS.ACTIVITY_ROW}-${eventType}-${index}`, - ); - } - - // Text elements within rows - getEventRowTitle(eventType: string, index: number) { - return Matchers.getElementByID( - `${REWARDS_VIEW_SELECTORS.ACTIVITY_EVENT_ROW_TITLE}-${REWARDS_VIEW_SELECTORS.ACTIVITY_ROW}-${eventType}-${index}`, - ); - } - - getEventRowValue(eventType: string, index: number) { - return Matchers.getElementByID( - `${REWARDS_VIEW_SELECTORS.ACTIVITY_EVENT_ROW_VALUE}-${REWARDS_VIEW_SELECTORS.ACTIVITY_ROW}-${eventType}-${index}`, - ); - } - - getEventRowDetails(eventType: string, index: number) { - return Matchers.getElementByID( - `${REWARDS_VIEW_SELECTORS.ACTIVITY_EVENT_ROW_DETAILS}-${REWARDS_VIEW_SELECTORS.ACTIVITY_ROW}-${eventType}-${index}`, - ); - } - - getEventRowDate(eventType: string, index: number) { - return Matchers.getElementByID( - `${REWARDS_VIEW_SELECTORS.ACTIVITY_EVENT_ROW_DATE}-${REWARDS_VIEW_SELECTORS.ACTIVITY_ROW}-${eventType}-${index}`, - ); - } - - // Specific text elements for common events - get signUpBonusTitle() { - return this.getEventRowTitle('sign_up_bonus', 0); - } - - get signUpBonusValue() { - return this.getEventRowValue('sign_up_bonus', 0); - } - - get firstSwapTitle() { - return this.getEventRowTitle('swap', 1); - } - - get firstSwapValue() { - return this.getEventRowValue('swap', 1); - } - - get firstSwapDetails() { - return this.getEventRowDetails('swap', 1); - } - - get perpsTitle() { - return this.getEventRowTitle('perps', 2); - } - - get perpsValue() { - return this.getEventRowValue('perps', 2); - } - - get perpsDetails() { - return this.getEventRowDetails('perps', 2); - } - - get referralTitle() { - return this.getEventRowTitle('referral', 3); - } - - get referralValue() { - return this.getEventRowValue('referral', 3); - } -} - -export default new RewardsActivityTabView(); diff --git a/e2e/pages/Rewards/RewardsOnboarding.ts b/e2e/pages/Rewards/RewardsOnboarding.ts deleted file mode 100644 index 7bf698f222a..00000000000 --- a/e2e/pages/Rewards/RewardsOnboarding.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { REWARDS_VIEW_SELECTORS } from '../../../app/components/UI/Rewards/Views/RewardsView.constants'; -import Assertions from '../../../tests/framework/Assertions'; -import Gestures from '../../../tests/framework/Gestures'; -import Matchers from '../../../tests/framework/Matchers'; - -class RewardsClaimBonus { - get rewardsClaimBonusButton(): DetoxElement { - return Matchers.getElementByID(REWARDS_VIEW_SELECTORS.CLAIM_BUTTON); - } - - get rewardsOnboardingNextButton(): DetoxElement { - return Matchers.getElementByID(REWARDS_VIEW_SELECTORS.NEXT_BUTTON); - } - - get rewardsOnboardingInfoScreen1(): DetoxElement { - return Matchers.getElementByText('Earn points on every trade'); - } - - get rewardsOnboardingInfoScreen2(): DetoxElement { - return Matchers.getElementByText('Level up for bigger perks'); - } - - get rewardsOnboardingInfoScreen3(): DetoxElement { - return Matchers.getElementByText('Exclusive seasonal rewards'); - } - - get rewardsOnboardingInfoScreen4(): DetoxElement { - return Matchers.getElementByText( - "You'll earn 250 points when you sign up!", - ); - } - - async tapRewardsClaimBonusButton(): Promise { - await Gestures.waitAndTap(this.rewardsClaimBonusButton, { - elemDescription: 'Rewards Claim 250 points button', - }); - } - - async existsRewardsOnboardingInfoScreen1() { - await Assertions.expectElementToBeVisible( - this.rewardsOnboardingInfoScreen1, - { - elemDescription: - 'Rewards Onboarding Info Screen - Earn points on every trade', - }, - ); - } - - async existsRewardsOnboardingInfoScreen2() { - await Assertions.expectElementToBeVisible( - this.rewardsOnboardingInfoScreen2, - { - elemDescription: - 'Rewards Onboarding Info Screen 2 - Level up for bigger perks', - }, - ); - } - - async existsRewardsOnboardingInfoScreen3() { - await Assertions.expectElementToBeVisible( - this.rewardsOnboardingInfoScreen3, - { - elemDescription: - 'Rewards Onboarding Info Screen 3 - Exclusive seasonal rewards', - }, - ); - } - - async existsRewardsOnboardingInfoScreen4() { - await Assertions.expectElementToBeVisible( - this.rewardsOnboardingInfoScreen4, - { - elemDescription: - "Rewards Onboarding Info Screen 4 - You'll earn 250 points when you sign up!", - }, - ); - } - - async tapRewardsOnboardingNextButton(): Promise { - await Gestures.waitAndTap(this.rewardsOnboardingNextButton, { - elemDescription: 'Rewards Onboarding Next Button', - }); - } -} - -export default new RewardsClaimBonus(); diff --git a/e2e/pages/Rewards/RewardsView.ts b/e2e/pages/Rewards/RewardsView.ts deleted file mode 100644 index 57b6af817a3..00000000000 --- a/e2e/pages/Rewards/RewardsView.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { REWARDS_VIEW_SELECTORS } from '../../../app/components/UI/Rewards/Views/RewardsView.constants'; -import { Gestures, Matchers } from '../../../tests/framework'; - -class RewardsView { - get rewardsOverviewTabButton(): DetoxElement { - return Matchers.getElementByID( - `${REWARDS_VIEW_SELECTORS.TAB_CONTROL}-bar-tab-0`, - ); - } - - get rewardActivityTabButton(): DetoxElement { - return Matchers.getElementByID( - `${REWARDS_VIEW_SELECTORS.TAB_CONTROL}-bar-tab-2`, - ); - } - - get rewardLevelsTabButton(): DetoxElement { - return Matchers.getElementByID( - `${REWARDS_VIEW_SELECTORS.TAB_CONTROL}-bar-tab-1`, - ); - } - - get seasonStatusLevel(): DetoxElement { - return Matchers.getElementByID(REWARDS_VIEW_SELECTORS.SEASON_STATUS_LEVEL); - } - - get seasonStatusTierName(): DetoxElement { - return Matchers.getElementByID( - REWARDS_VIEW_SELECTORS.SEASON_STATUS_TIER_NAME, - ); - } - - get seasonStatusPoints(): DetoxElement { - return Matchers.getElementByID(REWARDS_VIEW_SELECTORS.SEASON_STATUS_POINTS); - } - - async tapRewardsOverviewTabButton(): Promise { - await Gestures.waitAndTap(this.rewardsOverviewTabButton, { - elemDescription: 'Rewards Overview Tab Button', - }); - } - - async tapRewardActivityTabButton(): Promise { - await Gestures.waitAndTap(this.rewardActivityTabButton, { - elemDescription: 'Reward Activity Tab Button', - }); - } - - async tapRewardLevelsTabButton(): Promise { - await Gestures.waitAndTap(this.rewardLevelsTabButton, { - elemDescription: 'Reward Levels Tab Button', - }); - } -} - -export default new RewardsView(); diff --git a/e2e/specs/confirmations/signatures/alert-system.spec.ts b/e2e/specs/confirmations/signatures/alert-system.spec.ts index 438b38636c3..f1c45ac789e 100644 --- a/e2e/specs/confirmations/signatures/alert-system.spec.ts +++ b/e2e/specs/confirmations/signatures/alert-system.spec.ts @@ -5,7 +5,7 @@ import RequestTypes from '../../../pages/Browser/Confirmations/RequestTypes'; import AlertSystem from '../../../pages/Browser/Confirmations/AlertSystem'; import TestDApp from '../../../pages/Browser/TestDApp'; import { loginToApp, navigateToBrowserView } from '../../../viewHelper'; -import { SmokeConfirmationsRedesigned } from '../../../tags'; +import { SmokeConfirmations } from '../../../tags'; import { withFixtures } from '../../../../tests/framework/fixtures/FixtureHelper'; import FooterActions from '../../../pages/Browser/Confirmations/FooterActions'; import { @@ -36,7 +36,7 @@ const typedSignRequestBody = { ], }; -describe(SmokeConfirmationsRedesigned('Alert System - Signature'), () => { +describe(SmokeConfirmations('Alert System - Signature'), () => { const runTest = async ( testSpecificMock: (mockServer: Mockttp) => Promise, alertAssertion: () => Promise, diff --git a/e2e/specs/confirmations/signatures/signatures-typed.spec.ts b/e2e/specs/confirmations/signatures/signatures-typed.spec.ts index b8f96451891..319c069eaf8 100644 --- a/e2e/specs/confirmations/signatures/signatures-typed.spec.ts +++ b/e2e/specs/confirmations/signatures/signatures-typed.spec.ts @@ -6,7 +6,7 @@ import RequestTypes from '../../../pages/Browser/Confirmations/RequestTypes'; import TestDApp from '../../../pages/Browser/TestDApp'; import { loginToApp, navigateToBrowserView } from '../../../viewHelper'; import { withFixtures } from '../../../../tests/framework/fixtures/FixtureHelper'; -import { SmokeConfirmationsRedesigned } from '../../../tags'; +import { SmokeConfirmations } from '../../../tags'; import { buildPermissions, AnvilPort, @@ -46,7 +46,7 @@ const SIGNATURE_LIST = [ }, ]; -describe(SmokeConfirmationsRedesigned('Typed Signature Requests'), () => { +describe(SmokeConfirmations('Typed Signature Requests'), () => { const testSpecificMock = async (mockServer: Mockttp) => { await setupRemoteFeatureFlagsMock( mockServer, diff --git a/e2e/specs/confirmations/signatures/signatures.spec.ts b/e2e/specs/confirmations/signatures/signatures.spec.ts index 4c1406f5a21..258f04c3889 100644 --- a/e2e/specs/confirmations/signatures/signatures.spec.ts +++ b/e2e/specs/confirmations/signatures/signatures.spec.ts @@ -6,7 +6,7 @@ import RequestTypes from '../../../pages/Browser/Confirmations/RequestTypes'; import TestDApp from '../../../pages/Browser/TestDApp'; import { loginToApp, navigateToBrowserView } from '../../../viewHelper'; import { withFixtures } from '../../../../tests/framework/fixtures/FixtureHelper'; -import { SmokeConfirmationsRedesigned } from '../../../tags'; +import { SmokeConfirmations } from '../../../tags'; import { buildPermissions, AnvilPort, @@ -40,7 +40,7 @@ const SIGNATURE_LIST = [ }, ]; -describe(SmokeConfirmationsRedesigned('Signature Requests'), () => { +describe(SmokeConfirmations('Signature Requests'), () => { const testSpecificMock = async (mockServer: Mockttp) => { await setupRemoteFeatureFlagsMock( mockServer, diff --git a/e2e/specs/confirmations/transactions/7702/batch-transaction.spec.ts b/e2e/specs/confirmations/transactions/7702/batch-transaction.spec.ts index 292d3f6a730..89683d26a0b 100644 --- a/e2e/specs/confirmations/transactions/7702/batch-transaction.spec.ts +++ b/e2e/specs/confirmations/transactions/7702/batch-transaction.spec.ts @@ -17,7 +17,7 @@ import { buildPermissions, } from '../../../../../tests/framework/fixtures/FixtureUtils'; import { loginToApp, navigateToBrowserView } from '../../../../viewHelper'; -import { SmokeConfirmationsRedesigned } from '../../../../tags'; +import { SmokeConfirmations } from '../../../../tags'; import { withFixtures } from '../../../../../tests/framework/fixtures/FixtureHelper'; import { DappVariants } from '../../../../../tests/framework/Constants'; import { @@ -84,7 +84,7 @@ async function connectTestDappToLocalhost() { * Click event on switch account toggle is not working in the e2e tests. * Once that is fixed, the test cases can be uncommented and unskipped. */ -describe(SmokeConfirmationsRedesigned('7702 - smart account'), () => { +describe(SmokeConfirmations('7702 - smart account'), () => { const testSpecificMock = async (mockServer: Mockttp) => { await setupMockRequest(mockServer, { requestMethod: 'GET', diff --git a/e2e/specs/confirmations/transactions/contract-deployment.spec.ts b/e2e/specs/confirmations/transactions/contract-deployment.spec.ts index 56a21911336..8a97e552b1e 100644 --- a/e2e/specs/confirmations/transactions/contract-deployment.spec.ts +++ b/e2e/specs/confirmations/transactions/contract-deployment.spec.ts @@ -1,4 +1,4 @@ -import { SmokeConfirmationsRedesigned } from '../../../tags'; +import { SmokeConfirmations } from '../../../tags'; import { loginToApp, navigateToBrowserView } from '../../../viewHelper'; import Browser from '../../../pages/Browser/BrowserView'; import FixtureBuilder from '../../../../tests/framework/fixtures/FixtureBuilder'; @@ -22,7 +22,7 @@ import { confirmationFeatureFlags } from '../../../../tests/api-mocking/mock-res import { LocalNode } from '../../../../tests/framework/types'; import { AnvilManager } from '../../../../tests/seeder/anvil-manager'; -describe(SmokeConfirmationsRedesigned('Contract Deployment'), () => { +describe(SmokeConfirmations('Contract Deployment'), () => { const testSpecificMock = async (mockServer: Mockttp) => { await setupMockRequest(mockServer, { requestMethod: 'GET', diff --git a/e2e/specs/confirmations/transactions/contract-interaction.spec.ts b/e2e/specs/confirmations/transactions/contract-interaction.spec.ts index 2c5430ad778..8ba48ac77ee 100644 --- a/e2e/specs/confirmations/transactions/contract-interaction.spec.ts +++ b/e2e/specs/confirmations/transactions/contract-interaction.spec.ts @@ -1,5 +1,5 @@ import { SMART_CONTRACTS } from '../../../../app/util/test/smart-contracts'; -import { SmokeConfirmationsRedesigned } from '../../../tags'; +import { SmokeConfirmations } from '../../../tags'; import { loginToApp, navigateToBrowserView } from '../../../viewHelper'; import FixtureBuilder from '../../../../tests/framework/fixtures/FixtureBuilder'; import TabBarComponent from '../../../pages/wallet/TabBarComponent'; @@ -23,7 +23,7 @@ import { LocalNode } from '../../../../tests/framework/types'; import { AnvilManager } from '../../../../tests/seeder/anvil-manager'; import Browser from '../../../pages/Browser/BrowserView'; -describe(SmokeConfirmationsRedesigned('Contract Interaction'), () => { +describe(SmokeConfirmations('Contract Interaction'), () => { const NFT_CONTRACT = SMART_CONTRACTS.NFTS; const testSpecificMock = async (mockServer: Mockttp) => { diff --git a/e2e/specs/confirmations/transactions/dapp-initiated-transfer.spec.ts b/e2e/specs/confirmations/transactions/dapp-initiated-transfer.spec.ts index e406bcbf7b8..2979c4f14ab 100644 --- a/e2e/specs/confirmations/transactions/dapp-initiated-transfer.spec.ts +++ b/e2e/specs/confirmations/transactions/dapp-initiated-transfer.spec.ts @@ -1,4 +1,4 @@ -import { SmokeConfirmationsRedesigned } from '../../../tags'; +import { SmokeConfirmations } from '../../../tags'; import { loginToApp, navigateToBrowserView } from '../../../viewHelper'; import Browser from '../../../pages/Browser/BrowserView'; import FixtureBuilder from '../../../../tests/framework/fixtures/FixtureBuilder'; @@ -55,7 +55,7 @@ const expectedEventNames = [ expectedEvents.TRANSACTION_FINALIZED, ]; -describe.skip(SmokeConfirmationsRedesigned('DApp Initiated Transfer'), () => { +describe.skip(SmokeConfirmations('DApp Initiated Transfer'), () => { const testSpecificMock = async (mockServer: Mockttp) => { await setupMockPostRequest( mockServer, diff --git a/e2e/specs/confirmations/transactions/gas-fee-tokens-eip-7702-sponsored.spec.ts b/e2e/specs/confirmations/transactions/gas-fee-tokens-eip-7702-sponsored.spec.ts index ce188cc5ece..f4c7d80a015 100644 --- a/e2e/specs/confirmations/transactions/gas-fee-tokens-eip-7702-sponsored.spec.ts +++ b/e2e/specs/confirmations/transactions/gas-fee-tokens-eip-7702-sponsored.spec.ts @@ -9,7 +9,7 @@ import { LocalNodeType, Utilities, } from '../../../../tests/framework'; -import { SmokeConfirmationsRedesigned } from '../../../tags'; +import { SmokeConfirmations } from '../../../tags'; import { AnvilPort } from '../../../../tests/framework/fixtures/FixtureUtils'; import { loginToApp } from '../../../viewHelper'; import { withFixtures } from '../../../../tests/framework/fixtures/FixtureHelper'; @@ -176,9 +176,7 @@ const performSendTransaction = async () => { }; describe( - SmokeConfirmationsRedesigned( - 'Send native asset using EIP-7702 - Success Case', - ), + SmokeConfirmations('Send native asset using EIP-7702 - Success Case'), () => { beforeAll(async () => { jest.setTimeout(2500000); @@ -237,9 +235,7 @@ describe( ); describe( - SmokeConfirmationsRedesigned( - 'Send native asset using EIP-7702 - Failure Case', - ), + SmokeConfirmations('Send native asset using EIP-7702 - Failure Case'), () => { beforeAll(async () => { jest.setTimeout(2500000); diff --git a/e2e/specs/confirmations/transactions/gas-fee-tokens-eip-7702.spec.ts b/e2e/specs/confirmations/transactions/gas-fee-tokens-eip-7702.spec.ts index 9db58e85ae3..2492f0cb7da 100644 --- a/e2e/specs/confirmations/transactions/gas-fee-tokens-eip-7702.spec.ts +++ b/e2e/specs/confirmations/transactions/gas-fee-tokens-eip-7702.spec.ts @@ -9,7 +9,7 @@ import { LocalNodeType, Matchers, } from '../../../../tests/framework'; -import { SmokeConfirmationsRedesigned } from '../../../tags'; +import { SmokeConfirmations } from '../../../tags'; import { loginToApp } from '../../../viewHelper'; import { withFixtures } from '../../../../tests/framework/fixtures/FixtureHelper'; import RowComponents from '../../../pages/Browser/Confirmations/RowComponents'; @@ -140,7 +140,7 @@ const SIMULATION_GAS_STATION_MOCK = { }; describe( - SmokeConfirmationsRedesigned('Send native asset Gas Station using EIP-7702'), + SmokeConfirmations('Send native asset Gas Station using EIP-7702'), () => { beforeAll(async () => { jest.setTimeout(2500000); diff --git a/e2e/specs/confirmations/transactions/per-dapp-selected-network.spec.ts b/e2e/specs/confirmations/transactions/per-dapp-selected-network.spec.ts index b597c16f199..0ab1029e957 100644 --- a/e2e/specs/confirmations/transactions/per-dapp-selected-network.spec.ts +++ b/e2e/specs/confirmations/transactions/per-dapp-selected-network.spec.ts @@ -11,7 +11,7 @@ import TestDApp from '../../../pages/Browser/TestDApp.ts'; import NetworkListModal from '../../../pages/Network/NetworkListModal.ts'; import TabBarComponent from '../../../pages/wallet/TabBarComponent.ts'; import WalletView from '../../../pages/wallet/WalletView.ts'; -import { SmokeConfirmationsRedesigned } from '../../../tags.js'; +import { SmokeConfirmations } from '../../../tags.js'; import Assertions from '../../../../tests/framework/Assertions.ts'; import { loginToApp, navigateToBrowserView } from '../../../viewHelper.ts'; import { DappVariants } from '../../../../tests/framework/Constants.ts'; @@ -30,7 +30,7 @@ async function changeNetworkFromNetworkListModal(networkName: string) { await NetworkListModal.changeNetworkTo(networkName); } -describe(SmokeConfirmationsRedesigned('Dapp Network Switching'), () => { +describe(SmokeConfirmations('Dapp Network Switching'), () => { const testSpecificMock = async (mockServer: Mockttp) => { await setupRemoteFeatureFlagsMock( mockServer, diff --git a/e2e/specs/confirmations/transactions/token-approve/approve.spec.ts b/e2e/specs/confirmations/transactions/token-approve/approve.spec.ts index 48a73799383..ea734fde4d0 100644 --- a/e2e/specs/confirmations/transactions/token-approve/approve.spec.ts +++ b/e2e/specs/confirmations/transactions/token-approve/approve.spec.ts @@ -1,5 +1,5 @@ import { SMART_CONTRACTS } from '../../../../../app/util/test/smart-contracts'; -import { SmokeConfirmationsRedesigned } from '../../../../tags'; +import { SmokeConfirmations } from '../../../../tags'; import { loginToApp, navigateToBrowserView } from '../../../../viewHelper'; import FixtureBuilder from '../../../../../tests/framework/fixtures/FixtureBuilder'; import TabBarComponent from '../../../../pages/wallet/TabBarComponent'; @@ -24,7 +24,7 @@ import { confirmationFeatureFlags } from '../../../../../tests/api-mocking/mock- import { LocalNode } from '../../../../../tests/framework/types'; import { AnvilManager } from '../../../../../tests/seeder/anvil-manager'; -describe(SmokeConfirmationsRedesigned('Token Approve - approve method'), () => { +describe(SmokeConfirmations('Token Approve - approve method'), () => { const ERC_20_CONTRACT = SMART_CONTRACTS.HST; const ERC_721_CONTRACT = SMART_CONTRACTS.NFTS; diff --git a/e2e/specs/confirmations/transactions/token-approve/increase-allowance.spec.ts b/e2e/specs/confirmations/transactions/token-approve/increase-allowance.spec.ts index 54928dd777b..95a12a6942c 100644 --- a/e2e/specs/confirmations/transactions/token-approve/increase-allowance.spec.ts +++ b/e2e/specs/confirmations/transactions/token-approve/increase-allowance.spec.ts @@ -1,5 +1,5 @@ import { SMART_CONTRACTS } from '../../../../../app/util/test/smart-contracts'; -import { SmokeConfirmationsRedesigned } from '../../../../tags'; +import { SmokeConfirmations } from '../../../../tags'; import { loginToApp, navigateToBrowserView } from '../../../../viewHelper'; import FixtureBuilder from '../../../../../tests/framework/fixtures/FixtureBuilder'; import TabBarComponent from '../../../../pages/wallet/TabBarComponent'; @@ -24,122 +24,115 @@ import { setupRemoteFeatureFlagsMock } from '../../../../../tests/api-mocking/he import { LocalNode } from '../../../../../tests/framework/types'; import { AnvilManager } from '../../../../../tests/seeder/anvil-manager'; -describe( - SmokeConfirmationsRedesigned('Token Approve - increaseAllowance method'), - () => { - const ERC_20_CONTRACT = SMART_CONTRACTS.HST; +describe(SmokeConfirmations('Token Approve - increaseAllowance method'), () => { + const ERC_20_CONTRACT = SMART_CONTRACTS.HST; - const testSpecificMock = async (mockServer: Mockttp) => { - await setupMockRequest(mockServer, { - requestMethod: 'GET', - url: SIMULATION_ENABLED_NETWORKS_MOCK.urlEndpoint, - response: SIMULATION_ENABLED_NETWORKS_MOCK.response, - responseCode: 200, - }); - await setupRemoteFeatureFlagsMock( - mockServer, - Object.assign({}, ...confirmationFeatureFlags), - ); - }; - - it('creates an approve transaction confirmation for given ERC 20, changes the spending cap and submits it', async () => { - await withFixtures( - { - dapps: [ - { - dappVariant: DappVariants.TEST_DAPP, - }, - ], - fixture: ({ localNodes }: { localNodes?: LocalNode[] }) => { - const node = localNodes?.[0] as unknown as AnvilManager; - const rpcPort = - node instanceof AnvilManager - ? (node.getPort() ?? AnvilPort()) - : undefined; + const testSpecificMock = async (mockServer: Mockttp) => { + await setupMockRequest(mockServer, { + requestMethod: 'GET', + url: SIMULATION_ENABLED_NETWORKS_MOCK.urlEndpoint, + response: SIMULATION_ENABLED_NETWORKS_MOCK.response, + responseCode: 200, + }); + await setupRemoteFeatureFlagsMock( + mockServer, + Object.assign({}, ...confirmationFeatureFlags), + ); + }; - return new FixtureBuilder() - .withNetworkController({ - providerConfig: { - chainId: '0x539', - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Local RPC', - ticker: 'ETH', - }, - }) - .withPermissionControllerConnectedToTestDapp( - buildPermissions(['0x539']), - ) - .build(); + it('creates an approve transaction confirmation for given ERC 20, changes the spending cap and submits it', async () => { + await withFixtures( + { + dapps: [ + { + dappVariant: DappVariants.TEST_DAPP, }, - restartDevice: true, - testSpecificMock, - smartContracts: [ERC_20_CONTRACT], + ], + fixture: ({ localNodes }: { localNodes?: LocalNode[] }) => { + const node = localNodes?.[0] as unknown as AnvilManager; + const rpcPort = + node instanceof AnvilManager + ? (node.getPort() ?? AnvilPort()) + : undefined; + + return new FixtureBuilder() + .withNetworkController({ + providerConfig: { + chainId: '0x539', + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Local RPC', + ticker: 'ETH', + }, + }) + .withPermissionControllerConnectedToTestDapp( + buildPermissions(['0x539']), + ) + .build(); }, - async ({ contractRegistry }) => { - const erc20Address = - await contractRegistry?.getContractAddress(ERC_20_CONTRACT); + restartDevice: true, + testSpecificMock, + smartContracts: [ERC_20_CONTRACT], + }, + async ({ contractRegistry }) => { + const erc20Address = + await contractRegistry?.getContractAddress(ERC_20_CONTRACT); - await loginToApp(); + await loginToApp(); - await navigateToBrowserView(); - await TestDApp.navigateToTestDappWithContract({ - contractAddress: erc20Address, - }); + await navigateToBrowserView(); + await TestDApp.navigateToTestDappWithContract({ + contractAddress: erc20Address, + }); - await TestDApp.tapIncreaseAllowanceButton(); + await TestDApp.tapIncreaseAllowanceButton(); - // Check confirmation modal is visible - await Assertions.expectElementToBeVisible( - ConfirmationUITypes.ModalConfirmationContainer, - ); + // Check confirmation modal is visible + await Assertions.expectElementToBeVisible( + ConfirmationUITypes.ModalConfirmationContainer, + ); - // Check all expected row components are visible - await Assertions.expectElementToBeVisible( - RowComponents.AccountNetwork, - ); - await Assertions.expectElementToBeVisible(RowComponents.ApproveRow); - await Assertions.expectElementToBeVisible( - RowComponents.NetworkAndOrigin, - ); - await Assertions.expectElementToBeVisible( - RowComponents.GasFeesDetails, - ); - await Assertions.expectElementToBeVisible( - RowComponents.AdvancedDetails, - ); + // Check all expected row components are visible + await Assertions.expectElementToBeVisible(RowComponents.AccountNetwork); + await Assertions.expectElementToBeVisible(RowComponents.ApproveRow); + await Assertions.expectElementToBeVisible( + RowComponents.NetworkAndOrigin, + ); + await Assertions.expectElementToBeVisible(RowComponents.GasFeesDetails); + await Assertions.expectElementToBeVisible( + RowComponents.AdvancedDetails, + ); - // Check spending cap is visible and has the correct value - await Assertions.expectElementToHaveText( - TokenApproveConfirmation.SpendingCapValue, - '1', - ); + // Check spending cap is visible and has the correct value + await Assertions.expectElementToHaveText( + TokenApproveConfirmation.SpendingCapValue, + '1', + ); - // Change the spending cap to 10 and save it - await TokenApproveConfirmation.tapEditSpendingCapButton(); - await TokenApproveConfirmation.inputSpendingCap('5'); - await TokenApproveConfirmation.tapEditSpendingCapSaveButton(); - await Assertions.expectElementToHaveText( - TokenApproveConfirmation.SpendingCapValue, - '5', - ); + // Change the spending cap to 10 and save it + await TokenApproveConfirmation.tapEditSpendingCapButton(); + await TokenApproveConfirmation.inputSpendingCap('5'); + await TokenApproveConfirmation.tapEditSpendingCapSaveButton(); + await Assertions.expectElementToHaveText( + TokenApproveConfirmation.SpendingCapValue, + '5', + ); - // Accept confirmation - await FooterActions.tapConfirmButton(); + // Accept confirmation + await FooterActions.tapConfirmButton(); - // Wait for browser screen to be visible after confirmation modal dismisses - await Assertions.expectElementToBeVisible(Browser.browserScreenID, { - description: - 'Browser screen should be visible after confirming transaction', - }); + // Wait for browser screen to be visible after confirmation modal dismisses + await Assertions.expectElementToBeVisible(Browser.browserScreenID, { + description: + 'Browser screen should be visible after confirming transaction', + }); - // Close browser to reveal app tab bar, then check activity - await Browser.tapCloseBrowserButton(); - await TabBarComponent.tapActivity(); - await Assertions.expectTextDisplayed('Increase allowance'); - await Assertions.expectTextDisplayed('Confirmed'); - }, - ); - }); - }, -); + // Close browser to reveal app tab bar, then check activity + await Browser.tapCloseBrowserButton(); + await TabBarComponent.tapActivity(); + await Assertions.expectTextDisplayed('Increase allowance'); + await Assertions.expectTextDisplayed('Confirmed'); + }, + ); + }); +}); diff --git a/e2e/specs/confirmations/transactions/token-approve/set-approval-for-all.spec.ts b/e2e/specs/confirmations/transactions/token-approve/set-approval-for-all.spec.ts index 9c1f10b213b..c6ff9a64036 100644 --- a/e2e/specs/confirmations/transactions/token-approve/set-approval-for-all.spec.ts +++ b/e2e/specs/confirmations/transactions/token-approve/set-approval-for-all.spec.ts @@ -1,5 +1,5 @@ import { SMART_CONTRACTS } from '../../../../../app/util/test/smart-contracts'; -import { SmokeConfirmationsRedesigned } from '../../../../tags'; +import { SmokeConfirmations } from '../../../../tags'; import { loginToApp, navigateToBrowserView } from '../../../../viewHelper'; import FixtureBuilder from '../../../../../tests/framework/fixtures/FixtureBuilder'; import TabBarComponent from '../../../../pages/wallet/TabBarComponent'; @@ -24,134 +24,209 @@ import { confirmationFeatureFlags } from '../../../../../tests/api-mocking/mock- import { LocalNode } from '../../../../../tests/framework/types'; import { AnvilManager } from '../../../../../tests/seeder/anvil-manager'; -describe( - SmokeConfirmationsRedesigned('Token Approve - setApprovalForAll method'), - () => { - const ERC_721_CONTRACT = SMART_CONTRACTS.NFTS; - const ERC_1155_CONTRACT = SMART_CONTRACTS.ERC1155; - - const testSpecificMock = async (mockServer: Mockttp) => { - await setupMockRequest(mockServer, { - requestMethod: 'GET', - url: SIMULATION_ENABLED_NETWORKS_MOCK.urlEndpoint, - response: SIMULATION_ENABLED_NETWORKS_MOCK.response, - responseCode: 200, - }); - await setupRemoteFeatureFlagsMock( - mockServer, - Object.assign({}, ...confirmationFeatureFlags), - ); - }; - - it('creates an approve transaction confirmation for given ERC721 and submits it', async () => { - await withFixtures( - { - dapps: [ - { - dappVariant: DappVariants.TEST_DAPP, - }, - ], - fixture: ({ localNodes }: { localNodes?: LocalNode[] }) => { - const node = localNodes?.[0] as unknown as AnvilManager; - const rpcPort = - node instanceof AnvilManager - ? (node.getPort() ?? AnvilPort()) - : undefined; +describe(SmokeConfirmations('Token Approve - setApprovalForAll method'), () => { + const ERC_721_CONTRACT = SMART_CONTRACTS.NFTS; + const ERC_1155_CONTRACT = SMART_CONTRACTS.ERC1155; - return new FixtureBuilder() - .withNetworkController({ - providerConfig: { - chainId: '0x539', - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Local RPC', - ticker: 'ETH', - }, - }) - .withPermissionControllerConnectedToTestDapp( - buildPermissions(['0x539']), - ) - .build(); + const testSpecificMock = async (mockServer: Mockttp) => { + await setupMockRequest(mockServer, { + requestMethod: 'GET', + url: SIMULATION_ENABLED_NETWORKS_MOCK.urlEndpoint, + response: SIMULATION_ENABLED_NETWORKS_MOCK.response, + responseCode: 200, + }); + await setupRemoteFeatureFlagsMock( + mockServer, + Object.assign({}, ...confirmationFeatureFlags), + ); + }; + + it('creates an approve transaction confirmation for given ERC721 and submits it', async () => { + await withFixtures( + { + dapps: [ + { + dappVariant: DappVariants.TEST_DAPP, }, - restartDevice: true, - testSpecificMock, - smartContracts: [ERC_721_CONTRACT], + ], + fixture: ({ localNodes }: { localNodes?: LocalNode[] }) => { + const node = localNodes?.[0] as unknown as AnvilManager; + const rpcPort = + node instanceof AnvilManager + ? (node.getPort() ?? AnvilPort()) + : undefined; + + return new FixtureBuilder() + .withNetworkController({ + providerConfig: { + chainId: '0x539', + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Local RPC', + ticker: 'ETH', + }, + }) + .withPermissionControllerConnectedToTestDapp( + buildPermissions(['0x539']), + ) + .build(); }, - async ({ contractRegistry }) => { - const erc721Address = - await contractRegistry?.getContractAddress(ERC_721_CONTRACT); - - await loginToApp(); - - await navigateToBrowserView(); - await TestDApp.navigateToTestDappWithContract({ - contractAddress: erc721Address, - }); - - await TestDApp.tapNFTSetApprovalForAllButton(); - - // Check confirmation modal is visible - await Assertions.expectElementToBeVisible( - ConfirmationUITypes.ModalConfirmationContainer, - ); - - // Check all expected row components are visible - await Assertions.expectElementToBeVisible( - RowComponents.AccountNetwork, - { - description: 'Account Network', - }, - ); - await Assertions.expectElementToBeVisible(RowComponents.ApproveRow, { - description: 'Approve Row', - }); - await Assertions.expectElementToBeVisible( - RowComponents.NetworkAndOrigin, - { - description: 'Network And Origin', - }, - ); - await Assertions.expectElementToBeVisible( - RowComponents.GasFeesDetails, - { - description: 'Gas Fees Details', - }, - ); - await Assertions.expectElementToBeVisible( - RowComponents.AdvancedDetails, - { - description: 'Advanced Details', - }, - ); - - // Check spending cap is visible and has the correct value - await Assertions.expectElementToHaveText( - TokenApproveConfirmation.SpendingCapValue, - 'All', - { - description: 'Spending Cap Value', - }, - ); + restartDevice: true, + testSpecificMock, + smartContracts: [ERC_721_CONTRACT], + }, + async ({ contractRegistry }) => { + const erc721Address = + await contractRegistry?.getContractAddress(ERC_721_CONTRACT); + + await loginToApp(); + + await navigateToBrowserView(); + await TestDApp.navigateToTestDappWithContract({ + contractAddress: erc721Address, + }); + + await TestDApp.tapNFTSetApprovalForAllButton(); + + // Check confirmation modal is visible + await Assertions.expectElementToBeVisible( + ConfirmationUITypes.ModalConfirmationContainer, + ); - // Accept confirmation - await FooterActions.tapConfirmButton(); + // Check all expected row components are visible + await Assertions.expectElementToBeVisible( + RowComponents.AccountNetwork, + { + description: 'Account Network', + }, + ); + await Assertions.expectElementToBeVisible(RowComponents.ApproveRow, { + description: 'Approve Row', + }); + await Assertions.expectElementToBeVisible( + RowComponents.NetworkAndOrigin, + { + description: 'Network And Origin', + }, + ); + await Assertions.expectElementToBeVisible( + RowComponents.GasFeesDetails, + { + description: 'Gas Fees Details', + }, + ); + await Assertions.expectElementToBeVisible( + RowComponents.AdvancedDetails, + { + description: 'Advanced Details', + }, + ); - // Wait for browser screen to be visible after confirmation modal dismisses - await Assertions.expectElementToBeVisible(Browser.browserScreenID, { - description: - 'Browser screen should be visible after confirming transaction', - }); + // Check spending cap is visible and has the correct value + await Assertions.expectElementToHaveText( + TokenApproveConfirmation.SpendingCapValue, + 'All', + { + description: 'Spending Cap Value', + }, + ); - // Close browser to reveal app tab bar, then check activity - await Browser.tapCloseBrowserButton(); - await TabBarComponent.tapActivity(); - await Assertions.expectTextDisplayed('Set approval for all'); - await Assertions.expectTextDisplayed('Confirmed'); + // Accept confirmation + await FooterActions.tapConfirmButton(); + + // Wait for browser screen to be visible after confirmation modal dismisses + await Assertions.expectElementToBeVisible(Browser.browserScreenID, { + description: + 'Browser screen should be visible after confirming transaction', + }); + + // Close browser to reveal app tab bar, then check activity + await Browser.tapCloseBrowserButton(); + await TabBarComponent.tapActivity(); + await Assertions.expectTextDisplayed('Set approval for all'); + await Assertions.expectTextDisplayed('Confirmed'); + }, + ); + }); + + it('creates an approve transaction confirmation for given ERC1155 and submits it', async () => { + await withFixtures( + { + dapps: [ + { + dappVariant: DappVariants.TEST_DAPP, + }, + ], + fixture: ({ localNodes }: { localNodes?: LocalNode[] }) => { + const node = localNodes?.[0] as unknown as AnvilManager; + const rpcPort = + node instanceof AnvilManager + ? (node.getPort() ?? AnvilPort()) + : undefined; + + return new FixtureBuilder() + .withNetworkController({ + providerConfig: { + chainId: '0x539', + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Local RPC', + ticker: 'ETH', + }, + }) + .withPermissionControllerConnectedToTestDapp( + buildPermissions(['0x539']), + ) + .build(); }, - ); - }); + restartDevice: true, + testSpecificMock, + smartContracts: [ERC_1155_CONTRACT], + }, + async ({ contractRegistry }) => { + const erc1155Address = + await contractRegistry?.getContractAddress(ERC_1155_CONTRACT); + + await loginToApp(); + + await navigateToBrowserView(); + await TestDApp.navigateToTestDappWithContract({ + contractAddress: erc1155Address, + }); + + await TestDApp.tapERC1155SetApprovalForAllButton(); + + // Check confirmation modal is visible + await Assertions.expectElementToBeVisible( + ConfirmationUITypes.ModalConfirmationContainer, + ); + + // Check spending cap is visible and has the correct value + await Assertions.expectElementToHaveText( + TokenApproveConfirmation.SpendingCapValue, + 'All', + ); - it('creates an approve transaction confirmation for given ERC1155 and submits it', async () => { + // Accept confirmation + await FooterActions.tapConfirmButton(); + + // Wait for browser screen to be visible after confirmation modal dismisses + await Assertions.expectElementToBeVisible(Browser.browserScreenID, { + description: + 'Browser screen should be visible after confirming transaction', + }); + + // Close browser to reveal app tab bar, then check activity + await Browser.tapCloseBrowserButton(); + await TabBarComponent.tapActivity(); + await Assertions.expectTextDisplayed('Set approval for all'); + await Assertions.expectTextDisplayed('Confirmed'); + }, + ); + }); + + describe('revoke mode', () => { + it('creates an approve transaction confirmation for ERC 721 and submits it', async () => { await withFixtures( { dapps: [ @@ -183,20 +258,20 @@ describe( }, restartDevice: true, testSpecificMock, - smartContracts: [ERC_1155_CONTRACT], + smartContracts: [ERC_721_CONTRACT], }, async ({ contractRegistry }) => { - const erc1155Address = - await contractRegistry?.getContractAddress(ERC_1155_CONTRACT); + const erc721Address = + await contractRegistry?.getContractAddress(ERC_721_CONTRACT); await loginToApp(); await navigateToBrowserView(); await TestDApp.navigateToTestDappWithContract({ - contractAddress: erc1155Address, + contractAddress: erc721Address, }); - await TestDApp.tapERC1155SetApprovalForAllButton(); + await TestDApp.tapERC721RevokeApprovalButton(); // Check confirmation modal is visible await Assertions.expectElementToBeVisible( @@ -204,6 +279,7 @@ describe( ); // Check spending cap is visible and has the correct value + // All means, all token permissions revoked await Assertions.expectElementToHaveText( TokenApproveConfirmation.SpendingCapValue, 'All', @@ -212,12 +288,6 @@ describe( // Accept confirmation await FooterActions.tapConfirmButton(); - // Wait for browser screen to be visible after confirmation modal dismisses - await Assertions.expectElementToBeVisible(Browser.browserScreenID, { - description: - 'Browser screen should be visible after confirming transaction', - }); - // Close browser to reveal app tab bar, then check activity await Browser.tapCloseBrowserButton(); await TabBarComponent.tapActivity(); @@ -226,78 +296,5 @@ describe( }, ); }); - - describe('revoke mode', () => { - it('creates an approve transaction confirmation for ERC 721 and submits it', async () => { - await withFixtures( - { - dapps: [ - { - dappVariant: DappVariants.TEST_DAPP, - }, - ], - fixture: ({ localNodes }: { localNodes?: LocalNode[] }) => { - const node = localNodes?.[0] as unknown as AnvilManager; - const rpcPort = - node instanceof AnvilManager - ? (node.getPort() ?? AnvilPort()) - : undefined; - - return new FixtureBuilder() - .withNetworkController({ - providerConfig: { - chainId: '0x539', - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Local RPC', - ticker: 'ETH', - }, - }) - .withPermissionControllerConnectedToTestDapp( - buildPermissions(['0x539']), - ) - .build(); - }, - restartDevice: true, - testSpecificMock, - smartContracts: [ERC_721_CONTRACT], - }, - async ({ contractRegistry }) => { - const erc721Address = - await contractRegistry?.getContractAddress(ERC_721_CONTRACT); - - await loginToApp(); - - await navigateToBrowserView(); - await TestDApp.navigateToTestDappWithContract({ - contractAddress: erc721Address, - }); - - await TestDApp.tapERC721RevokeApprovalButton(); - - // Check confirmation modal is visible - await Assertions.expectElementToBeVisible( - ConfirmationUITypes.ModalConfirmationContainer, - ); - - // Check spending cap is visible and has the correct value - // All means, all token permissions revoked - await Assertions.expectElementToHaveText( - TokenApproveConfirmation.SpendingCapValue, - 'All', - ); - - // Accept confirmation - await FooterActions.tapConfirmButton(); - - // Close browser to reveal app tab bar, then check activity - await Browser.tapCloseBrowserButton(); - await TabBarComponent.tapActivity(); - await Assertions.expectTextDisplayed('Set approval for all'); - await Assertions.expectTextDisplayed('Confirmed'); - }, - ); - }); - }); - }, -); + }); +}); diff --git a/e2e/specs/confirmations/transactions/transaction-pay.spec.ts b/e2e/specs/confirmations/transactions/transaction-pay.spec.ts index 7642d466176..66206dd8010 100644 --- a/e2e/specs/confirmations/transactions/transaction-pay.spec.ts +++ b/e2e/specs/confirmations/transactions/transaction-pay.spec.ts @@ -1,6 +1,6 @@ import { withFixtures } from '../../../../tests/framework/fixtures/FixtureHelper'; import FixtureBuilder from '../../../../tests/framework/fixtures/FixtureBuilder'; -import { SmokeConfirmationsRedesigned } from '../../../tags'; +import { SmokeConfirmations } from '../../../tags'; import { loginToApp } from '../../../viewHelper'; import { remoteFeatureEip7702, @@ -24,7 +24,7 @@ import WalletActionsBottomSheet from '../../../pages/wallet/WalletActionsBottomS import ActivitiesView from '../../../pages/Transactions/ActivitiesView'; import PredictMarketList from '../../../../tests/page-objects/Predict/PredictMarketList'; -describe(SmokeConfirmationsRedesigned('Transaction Pay'), () => { +describe(SmokeConfirmations('Transaction Pay'), () => { it('deposits to predict balance', async () => { await withFixtures( { diff --git a/e2e/specs/quarantine/swap-action-regression.failing.ts b/e2e/specs/quarantine/swap-action-regression.failing.ts index 7242acc6b46..2071a8f608a 100644 --- a/e2e/specs/quarantine/swap-action-regression.failing.ts +++ b/e2e/specs/quarantine/swap-action-regression.failing.ts @@ -7,10 +7,10 @@ import { RegressionTrade } from '../../tags'; import { submitSwapUnifiedUI, checkSwapActivity, -} from '../swaps/helpers/swap-unified-ui'; +} from '../../../tests/helpers/swap/swap-unified-ui'; import { loginToApp } from '../../viewHelper'; -import { prepareSwapsTestEnvironment } from '../swaps/helpers/prepareSwapsTestEnvironment'; -import { testSpecificMock } from '../swaps/helpers/swap-mocks'; +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'; diff --git a/e2e/specs/send/send-btc-token.spec.ts b/e2e/specs/send/send-btc-token.spec.ts index 141a87eb4d2..995b1d0faf4 100644 --- a/e2e/specs/send/send-btc-token.spec.ts +++ b/e2e/specs/send/send-btc-token.spec.ts @@ -1,12 +1,12 @@ import SendView from '../../pages/Send/RedesignedSendView'; import TokenOverview from '../../pages/wallet/TokenOverview'; import WalletView from '../../pages/wallet/WalletView'; -import { SmokeConfirmationsRedesigned } from '../../tags'; +import { SmokeConfirmations } from '../../tags'; import { withFixtures } from '../../../tests/framework/fixtures/FixtureHelper'; import FixtureBuilder from '../../../tests/framework/fixtures/FixtureBuilder'; import { loginToApp } from '../../viewHelper'; -describe(SmokeConfirmationsRedesigned('Send Bitcoin'), () => { +describe(SmokeConfirmations('Send Bitcoin'), () => { it('shows insufficient funds', async () => { await withFixtures( { diff --git a/e2e/specs/send/send-erc20-token.spec.ts b/e2e/specs/send/send-erc20-token.spec.ts index 1323dd874e8..dbf76232e16 100644 --- a/e2e/specs/send/send-erc20-token.spec.ts +++ b/e2e/specs/send/send-erc20-token.spec.ts @@ -3,7 +3,7 @@ import FooterActions from '../../pages/Browser/Confirmations/FooterActions'; import SendView from '../../pages/Send/RedesignedSendView'; import WalletView from '../../pages/wallet/WalletView'; import { DappVariants } from '../../../tests/framework/Constants'; -import { SmokeConfirmationsRedesigned } from '../../tags'; +import { SmokeConfirmations } from '../../tags'; import { AnvilPort } from '../../../tests/framework/fixtures/FixtureUtils'; import { loginToApp } from '../../viewHelper'; import { withFixtures } from '../../../tests/framework/fixtures/FixtureHelper'; @@ -11,7 +11,7 @@ import { AnvilManager } from '../../../tests/seeder/anvil-manager'; const RECIPIENT = '0x0c54fccd2e384b4bb6f2e405bf5cbc15a017aafb'; -describe(SmokeConfirmationsRedesigned('Send ERC20 asset'), () => { +describe(SmokeConfirmations('Send ERC20 asset'), () => { it('should send USDC amount 5 to an address', async () => { await withFixtures( { diff --git a/e2e/specs/send/send-native-token.spec.ts b/e2e/specs/send/send-native-token.spec.ts index 55af1018e7a..a7f91b59017 100644 --- a/e2e/specs/send/send-native-token.spec.ts +++ b/e2e/specs/send/send-native-token.spec.ts @@ -8,7 +8,7 @@ import { DappVariants, LOCAL_NODE_RPC_URL, } from '../../../tests/framework/Constants'; -import { SmokeConfirmationsRedesigned } from '../../tags'; +import { SmokeConfirmations } from '../../tags'; import { loginToApp } from '../../viewHelper'; import { withFixtures } from '../../../tests/framework/fixtures/FixtureHelper'; import { LocalNode } from '../../../tests/framework/types'; @@ -20,7 +20,7 @@ import { validateTransactionHashInTransactionFinalizedEvent } from './metricsVal const RECIPIENT = '0x0c54fccd2e384b4bb6f2e405bf5cbc15a017aafb'; -describe(SmokeConfirmationsRedesigned('Send native asset'), () => { +describe(SmokeConfirmations('Send native asset'), () => { it('should send ETH to an address', async () => { await withFixtures( { diff --git a/e2e/specs/send/send-solana-token.spec.ts b/e2e/specs/send/send-solana-token.spec.ts index 0cf9ce739f3..36c286b8c16 100644 --- a/e2e/specs/send/send-solana-token.spec.ts +++ b/e2e/specs/send/send-solana-token.spec.ts @@ -2,14 +2,14 @@ import SendView from '../../pages/Send/RedesignedSendView'; import SolanaTestDApp from '../../pages/Browser/SolanaTestDApp'; import TokenOverview from '../../pages/wallet/TokenOverview'; import WalletView from '../../pages/wallet/WalletView'; -import { SmokeConfirmationsRedesigned } from '../../tags'; +import { SmokeConfirmations } from '../../tags'; import { loginToApp } from '../../viewHelper'; import { withFixtures } from '../../../tests/framework/fixtures/FixtureHelper'; import FixtureBuilder from '../../../tests/framework/fixtures/FixtureBuilder'; const RECIPIENT = '4Nd1mZyJY5ZqzR3n8bQF7h5L2Q9gY1yTtM6nQhc7P1Dp'; -describe(SmokeConfirmationsRedesigned('Send SOL token'), () => { +describe(SmokeConfirmations('Send SOL token'), () => { it('should send solana to an address', async () => { await withFixtures( { diff --git a/e2e/specs/send/send-tron-token.spec.ts b/e2e/specs/send/send-tron-token.spec.ts index eda25a94270..22057d72e2a 100644 --- a/e2e/specs/send/send-tron-token.spec.ts +++ b/e2e/specs/send/send-tron-token.spec.ts @@ -1,12 +1,12 @@ import SendView from '../../pages/Send/RedesignedSendView'; import TokenOverview from '../../pages/wallet/TokenOverview'; import WalletView from '../../pages/wallet/WalletView'; -import { SmokeConfirmationsRedesigned } from '../../tags'; +import { SmokeConfirmations } from '../../tags'; import { withFixtures } from '../../../tests/framework/fixtures/FixtureHelper'; import FixtureBuilder from '../../../tests/framework/fixtures/FixtureBuilder'; import { loginToApp } from '../../viewHelper'; -describe(SmokeConfirmationsRedesigned('Send TRX token'), () => { +describe(SmokeConfirmations('Send TRX token'), () => { it.skip('shows insufficient funds', async () => { await withFixtures( { diff --git a/e2e/tags.js b/e2e/tags.js index 3fbb43820a0..d03143ff337 100644 --- a/e2e/tags.js +++ b/e2e/tags.js @@ -11,10 +11,10 @@ const smokeTags = { description: 'Tests account security and multi-account management within the wallet. Covers Secret Recovery Phrase (SRP) protection flows including the reveal quiz validation in Settings, SRP export from both Settings and account action menus, and wallet details credential display. Also tests multi-account workflows: creating new HD wallet accounts, adding QR-based hardware wallet accounts, importing accounts via private key, account switching and selection, account renaming, and managing account visibility in the account list. Integrates with the AccountSelector and RevealPrivateCredential components. Related to SmokeWalletPlatform for multi-SRP architecture and SmokeIdentity for account sync features.', }, - smokeConfirmationsRedesigned: { - tag: 'SmokeConfirmationsRedesigned:', + smokeConfirmations: { + tag: 'SmokeConfirmations:', description: - 'Tests the redesigned transaction and signature confirmation UI system. Covers signature request types: personal_sign messages, Sign-In with Ethereum (SIWE/EIP-4361), and typed data signing (EIP-712 V1/V3/V4). Tests Blockaid security alert integration for detecting malicious signature requests. Validates smart contract interactions including contract deployment, method calls, and token approvals (ERC-20 approve, increaseAllowance, ERC-721/ERC-1155 setApprovalForAll). Tests transaction sending for native tokens (ETH), ERC-20 tokens, and Solana SPL tokens. Covers gas fee customization (EIP-1559 and legacy), transaction simulation previews, and advanced EIP-7702 account abstraction features like batch transactions and gas fee token payments. Also tests per-dApp network selection within confirmations. Integrates with SmokeTrade for swap/bridge confirmations and SmokeNetworkExpansion for cross-chain transactions.', + 'Tests the transaction and signature confirmation UI system. Covers signature request types: personal_sign messages, Sign-In with Ethereum (SIWE/EIP-4361), and typed data signing (EIP-712 V1/V3/V4). Tests Blockaid security alert integration for detecting malicious signature requests. Validates smart contract interactions including contract deployment, method calls, and token approvals (ERC-20 approve, increaseAllowance, ERC-721/ERC-1155 setApprovalForAll). Tests transaction sending for native tokens (ETH), ERC-20 tokens, and Solana SPL tokens. Covers gas fee customization (EIP-1559 and legacy), transaction simulation previews, and advanced EIP-7702 account abstraction features like batch transactions and gas fee token payments. Also tests per-dApp network selection within confirmations. Integrates with SmokeTrade for swap/bridge confirmations and SmokeNetworkExpansion for cross-chain transactions.', }, smokeIdentity: { tag: 'SmokeIdentity:', @@ -29,12 +29,12 @@ const smokeTags = { smokeNetworkExpansion: { tag: 'SmokeNetworkExpansion:', description: - 'Tests non-EVM blockchain support and multi-chain provider architecture. Focuses on Solana Wallet Standard compliance: dApp connect/disconnect flows, displaying Solana account addresses to dApps, account switching with dApp notification, session persistence after page refresh, SOL token transfers, and Solana message signing. Tests the multi-chain provider system enabling simultaneous connections from a single dApp to multiple blockchain networks (e.g., EVM and Solana together). Covers initial connection permission flows for multi-chain dApps and account selection across chain types. Integrates with SmokeNetworkAbstractions for chain permissions and SmokeConfirmationsRedesigned for Solana transaction confirmations.', + 'Tests non-EVM blockchain support and multi-chain provider architecture. Focuses on Solana Wallet Standard compliance: dApp connect/disconnect flows, displaying Solana account addresses to dApps, account switching with dApp notification, session persistence after page refresh, SOL token transfers, and Solana message signing. Tests the multi-chain provider system enabling simultaneous connections from a single dApp to multiple blockchain networks (e.g., EVM and Solana together). Covers initial connection permission flows for multi-chain dApps and account selection across chain types. Integrates with SmokeNetworkAbstractions for chain permissions and SmokeConfirmations for Solana transaction confirmations.', }, smokeTrade: { tag: 'SmokeTrade:', description: - 'Tests DeFi trading and financial features accessed via the Trade wallet actions menu. Covers token swaps: executing swaps (e.g., ETH to USDC) through the unified swap interface, quote fetching, and swap completion verification in activities. Tests cross-chain bridging between networks (e.g., Ethereum mainnet to Base). Validates gasless swap execution via Smart Transactions where users do not pay gas directly. Tests native ETH staking flows initiated from the wallet actions menu. Covers deep link navigation into swap and bridge screens from external sources. Validates analytics event tracking for swap started, swap completed, quotes received, and bridge events. The TradeWalletActions bottom sheet provides entry points to Swap, Bridge, Perps, Predictions, and Earn features. Related to SmokeConfirmationsRedesigned for transaction confirmations and SmokeWalletPlatform for activity display.', + 'Tests DeFi trading and financial features accessed via the Trade wallet actions menu. Covers token swaps: executing swaps (e.g., ETH to USDC) through the unified swap interface, quote fetching, and swap completion verification in activities. Tests cross-chain bridging between networks (e.g., Ethereum mainnet to Base). Validates gasless swap execution via Smart Transactions where users do not pay gas directly. Tests native ETH staking flows initiated from the wallet actions menu. Covers deep link navigation into swap and bridge screens from external sources. Validates analytics event tracking for swap started, swap completed, quotes received, and bridge events. The TradeWalletActions bottom sheet provides entry points to Swap, Bridge, Perps, Predictions, and Earn features. Related to SmokeConfirmations for transaction confirmations and SmokeWalletPlatform for activity display.', }, smokeWalletPlatform: { tag: 'SmokeWalletPlatform:', @@ -46,11 +46,6 @@ const smokeTags = { description: 'Tests MetaMask Card integration for crypto-to-fiat spending. Covers the Card home screen display showing card status and balance, the Add Funds button with Deposit and Swap funding options, and Advanced Card Management which opens the external card dashboard in the browser. Tests the Card navbar button for quick navigation to Card home. Validates card-related analytics events: Card Button Viewed, Card Home Clicked, Card Add Funds Clicked, and Card Advanced Management Clicked. The Card feature is controlled by experimental feature flags. Integrates with SmokeTrade for funding via swaps.', }, - smokeRewards: { - tag: 'SmokeRewards:', - description: - 'Tests the MetaMask Rewards incentive program. Covers the rewards opt-in onboarding flow with its multi-screen carousel, claiming sign-up bonuses, displaying reward tier levels (e.g., Level 1 "Origin"), and showing earned points balance. Tests the Activity tab displaying reward-earning actions including sign-up bonuses, perpetuals trading rewards, swap rewards, and referral activities. Note: Tests are currently skipped pending rewards system stabilization. When enabled, integrates with SmokeTrade for trading reward triggers.', - }, smokePerps: { tag: 'SmokePerps:', description: @@ -85,7 +80,6 @@ const flaskTags = { const otherTags = { regressionAccounts: 'RegressionAccounts:', regressionConfirmations: 'RegressionConfirmations:', - regressionConfirmationsRedesigned: 'RegressionConfirmationsRedesigned:', regressionIdentity: 'RegressionIdentity:', regressionNetworkAbstractions: 'RegressionNetworkAbstractions:', regressionWalletPlatform: 'RegressionWalletPlatform:', @@ -100,8 +94,8 @@ const otherTags = { // Smoke test tag functions const SmokeAccounts = (testName) => `${smokeTags.smokeAccounts.tag} ${testName}`; -const SmokeConfirmationsRedesigned = (testName) => - `${smokeTags.smokeConfirmationsRedesigned.tag} ${testName}`; +const SmokeConfirmations = (testName) => + `${smokeTags.smokeConfirmations.tag} ${testName}`; const SmokeIdentity = (testName) => `${smokeTags.smokeIdentity.tag} ${testName}`; const SmokeNetworkAbstractions = (testName) => @@ -112,7 +106,6 @@ const SmokeTrade = (testName) => `${smokeTags.smokeTrade.tag} ${testName}`; const SmokeWalletPlatform = (testName) => `${smokeTags.smokeWalletPlatform.tag} ${testName}`; const SmokeCard = (testName) => `${smokeTags.smokeCard.tag} ${testName}`; -const SmokeRewards = (testName) => `${smokeTags.smokeRewards.tag} ${testName}`; const SmokePerps = (testName) => `${smokeTags.smokePerps.tag} ${testName}`; const SmokeRamps = (testName) => `${smokeTags.smokeRamps.tag} ${testName}`; const SmokeMultiChainAPI = (testName) => @@ -124,8 +117,6 @@ const RegressionAccounts = (testName) => `${otherTags.regressionAccounts} ${testName}`; const RegressionConfirmations = (testName) => `${otherTags.regressionConfirmations} ${testName}`; -const RegressionConfirmationsRedesigned = (testName) => - `${otherTags.regressionConfirmationsRedesigned} ${testName}`; const RegressionIdentity = (testName) => `${otherTags.regressionIdentity} ${testName}`; const RegressionNetworkAbstractions = (testName) => @@ -150,21 +141,19 @@ export { smokeTags, flaskTags, SmokeAccounts, - SmokeConfirmationsRedesigned, + SmokeConfirmations, SmokeIdentity, SmokeNetworkAbstractions, SmokeNetworkExpansion, SmokeTrade, SmokeWalletPlatform, SmokeCard, - SmokeRewards, SmokePerps, SmokeRamps, SmokeMultiChainAPI, SmokePredictions, RegressionAccounts, RegressionConfirmations, - RegressionConfirmationsRedesigned, RegressionIdentity, RegressionNetworkAbstractions, RegressionWalletPlatform, diff --git a/locales/languages/en.json b/locales/languages/en.json index fe5b32f216c..543fae288d4 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1094,6 +1094,10 @@ "your_funds_are_available_to_trade": "Your funds are available to trade", "track": "Track" }, + "one_click_trade": { + "tx_creation_failed_title": "Could not open position", + "tx_creation_failed_description": "Transaction creation failed. Please try again." + }, "withdrawal": { "title": "Withdraw", "insufficient_funds": "Insufficient funds", @@ -6349,7 +6353,16 @@ "approval_tooltip_content": "You are allowing access to the specified amount, {{amount}} {{symbol}}. The contract will not access any additional funds.", "minimum_received": "Minimum received", "minimum_received_tooltip_title": "Minimum received", - "minimum_received_tooltip_content": "The minimum amount you'll receive if the price changes while your transaction is processing, based on your slippage tolerance. This is an estimate from our liquidity providers. Final amounts may differ." + "minimum_received_tooltip_content": "The minimum amount you'll receive if the price changes while your transaction is processing, based on your slippage tolerance. This is an estimate from our liquidity providers. Final amounts may differ.", + "submit": "Submit", + "default_slippage_description": "Your transaction won't go through if the price changes more than the slippage percent.", + "cancel": "Cancel", + "confirm": "Confirm", + "exceeding_upper_slippage_warning": "High slippage, this may result in a unfavourable swap", + "exceeding_lower_slippage_warning": "Low slippage, this may result in a unfavourable swap", + "exceeding_lower_slippage_error": "Enter a value greater than {{value}}%", + "exceeding_upper_slippage_error": "You cannot enter a value greater than {{value}}%", + "custom": "Custom" }, "quote_expired_modal": { "title": "New quotes are available", diff --git a/package.json b/package.json index 3b7e93ac490..6401c110390 100644 --- a/package.json +++ b/package.json @@ -206,7 +206,7 @@ "@metamask/analytics-controller": "^1.0.0", "@metamask/app-metadata-controller": "^2.0.0", "@metamask/approval-controller": "^8.0.0", - "@metamask/assets-controllers": "^98.0.0", + "@metamask/assets-controllers": "^99.0.0", "@metamask/base-controller": "^9.0.0", "@metamask/bitcoin-wallet-snap": "^1.9.0", "@metamask/bridge-controller": "^64.8.0", @@ -268,7 +268,7 @@ "@metamask/preinstalled-example-snap": "^0.7.2", "@metamask/profile-metrics-controller": "^2.0.0", "@metamask/profile-sync-controller": "^27.0.0", - "@metamask/ramps-controller": "^4.1.0", + "@metamask/ramps-controller": "^5.0.0", "@metamask/react-native-acm": "^1.0.1", "@metamask/react-native-actionsheet": "2.4.2", "@metamask/react-native-button": "^3.0.0", diff --git a/tests/api-mocking/mock-responses/ramps/ramps-region-aware-mock-setup.ts b/tests/api-mocking/mock-responses/ramps/ramps-region-aware-mock-setup.ts index 11c7083be6d..c7759d27c43 100644 --- a/tests/api-mocking/mock-responses/ramps/ramps-region-aware-mock-setup.ts +++ b/tests/api-mocking/mock-responses/ramps/ramps-region-aware-mock-setup.ts @@ -44,13 +44,13 @@ export const setupRegionAwareOnRampMocks = async ( // 3. Countries endpoints (both UAT and prod) - Add more countries as needed - RAMPS_COUNTRIES_RESPONSE in on-ramp-mocks.ts { urlEndpoint: - /^https:\/\/on-ramp-cache\.api\.cx\.metamask\.io\/regions\/countries\?.*$/, + /^https:\/\/on-ramp-cache\.api\.cx\.metamask\.io(\/v2)?\/regions\/countries\?.*$/, responseCode: 200, response: RAMPS_COUNTRIES_RESPONSE, }, { urlEndpoint: - /^https:\/\/on-ramp-cache\.uat-api\.cx\.metamask\.io\/regions\/countries\?.*$/, + /^https:\/\/on-ramp-cache\.uat-api\.cx\.metamask\.io(\/v2)?\/regions\/countries\?.*$/, responseCode: 200, response: RAMPS_COUNTRIES_RESPONSE, }, diff --git a/tests/framework/fixtures/FixtureBuilder.ts b/tests/framework/fixtures/FixtureBuilder.ts index 51a9045b9cf..06691856f05 100644 --- a/tests/framework/fixtures/FixtureBuilder.ts +++ b/tests/framework/fixtures/FixtureBuilder.ts @@ -270,7 +270,6 @@ class FixtureBuilder { ], }, }, - preventPollingOnNetworkRestart: false, }, CurrencyRateController: { currentCurrency: 'usd', diff --git a/e2e/specs/swaps/helpers/bridge-mocks.ts b/tests/helpers/swap/bridge-mocks.ts similarity index 91% rename from e2e/specs/swaps/helpers/bridge-mocks.ts rename to tests/helpers/swap/bridge-mocks.ts index 9505f4fe423..7f368efef28 100644 --- a/e2e/specs/swaps/helpers/bridge-mocks.ts +++ b/tests/helpers/swap/bridge-mocks.ts @@ -1,7 +1,7 @@ import { Mockttp } from 'mockttp'; -import { TestSpecificMock } from '../../../../tests/framework'; -import { setupMockRequest } from '../../../../tests/api-mocking/helpers/mockHelpers'; -import { setupRemoteFeatureFlagsMock } from '../../../../tests/api-mocking/helpers/remoteFeatureFlagsHelper'; +import { TestSpecificMock } from '../../framework'; +import { setupMockRequest } from '../../api-mocking/helpers/mockHelpers'; +import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; import { GET_TOKENS_MAINNET_RESPONSE, GET_TOKENS_SOLANA_RESPONSE, diff --git a/e2e/specs/swaps/helpers/constants.ts b/tests/helpers/swap/constants.ts similarity index 100% rename from e2e/specs/swaps/helpers/constants.ts rename to tests/helpers/swap/constants.ts diff --git a/e2e/specs/swaps/helpers/prepareSwapsTestEnvironment.ts b/tests/helpers/swap/prepareSwapsTestEnvironment.ts similarity index 66% rename from e2e/specs/swaps/helpers/prepareSwapsTestEnvironment.ts rename to tests/helpers/swap/prepareSwapsTestEnvironment.ts index 2a6e79669c1..833c9f25bc0 100644 --- a/e2e/specs/swaps/helpers/prepareSwapsTestEnvironment.ts +++ b/tests/helpers/swap/prepareSwapsTestEnvironment.ts @@ -1,6 +1,6 @@ -import TabBarComponent from '../../../pages/wallet/TabBarComponent'; -import SettingsView from '../../../pages/Settings/SettingsView'; -import AdvancedSettingsView from '../../../pages/Settings/AdvancedView'; +import TabBarComponent from '../../../e2e/pages/wallet/TabBarComponent'; +import SettingsView from '../../../e2e/pages/Settings/SettingsView'; +import AdvancedSettingsView from '../../../e2e/pages/Settings/AdvancedView'; /** * Prepares the swaps test environment by disabling Smart Transactions (stx). diff --git a/e2e/specs/swaps/helpers/swap-mocks.ts b/tests/helpers/swap/swap-mocks.ts similarity index 95% rename from e2e/specs/swaps/helpers/swap-mocks.ts rename to tests/helpers/swap/swap-mocks.ts index 9ab40427014..342a6d6daf3 100644 --- a/e2e/specs/swaps/helpers/swap-mocks.ts +++ b/tests/helpers/swap/swap-mocks.ts @@ -1,9 +1,9 @@ import { Mockttp } from 'mockttp'; -import { TestSpecificMock } from '../../../../tests/framework'; +import { TestSpecificMock } from '../../framework'; import { interceptProxyUrl, setupMockRequest, -} from '../../../../tests/api-mocking/helpers/mockHelpers'; +} from '../../api-mocking/helpers/mockHelpers'; import { GET_QUOTE_ETH_USDC_RESPONSE, GET_QUOTE_ETH_DAI_RESPONSE, diff --git a/e2e/specs/swaps/helpers/swap-unified-ui.ts b/tests/helpers/swap/swap-unified-ui.ts similarity index 82% rename from e2e/specs/swaps/helpers/swap-unified-ui.ts rename to tests/helpers/swap/swap-unified-ui.ts index 872c04bd8a5..96297739449 100644 --- a/e2e/specs/swaps/helpers/swap-unified-ui.ts +++ b/tests/helpers/swap/swap-unified-ui.ts @@ -1,8 +1,8 @@ -import TestHelpers from '../../../helpers'; -import QuoteView from '../../../pages/swaps/QuoteView'; -import Assertions from '../../../../tests/framework/Assertions'; -import ActivitiesView from '../../../pages/Transactions/ActivitiesView'; -import { ActivitiesViewSelectorsText } from '../../../../app/components/Views/ActivityView/ActivitiesView.testIds'; +import TestHelpers from '../../../e2e/helpers'; +import QuoteView from '../../../e2e/pages/swaps/QuoteView'; +import Assertions from '../../framework/Assertions'; +import ActivitiesView from '../../../e2e/pages/Transactions/ActivitiesView'; +import { ActivitiesViewSelectorsText } from '../../../app/components/Views/ActivityView/ActivitiesView.testIds'; export async function submitSwapUnifiedUI( quantity: string, diff --git a/e2e/specs/swaps/swap-token-chart.spec.ts b/tests/regression/swap/swap-token-chart.spec.ts similarity index 71% rename from e2e/specs/swaps/swap-token-chart.spec.ts rename to tests/regression/swap/swap-token-chart.spec.ts index 91193142dbf..34bccf6aadf 100644 --- a/e2e/specs/swaps/swap-token-chart.spec.ts +++ b/tests/regression/swap/swap-token-chart.spec.ts @@ -1,21 +1,21 @@ 'use strict'; -import { withFixtures } from '../../../tests/framework/fixtures/FixtureHelper'; -import { LocalNode, LocalNodeType } from '../../../tests/framework/types'; -import { loginToApp } from '../../viewHelper'; -import TabBarComponent from '../../pages/wallet/TabBarComponent'; -import WalletView from '../../pages/wallet/WalletView'; -import TokenOverview from '../../pages/wallet/TokenOverview'; -import FixtureBuilder from '../../../tests/framework/fixtures/FixtureBuilder'; -import TestHelpers from '../../helpers'; -import { RegressionTrade } from '../../tags'; -import Assertions from '../../../tests/framework/Assertions'; -import ActivitiesView from '../../pages/Transactions/ActivitiesView'; +import { withFixtures } from '../../framework/fixtures/FixtureHelper'; +import { LocalNode, LocalNodeType } from '../../framework/types'; +import { loginToApp } from '../../../e2e/viewHelper'; +import TabBarComponent from '../../../e2e/pages/wallet/TabBarComponent'; +import WalletView from '../../../e2e/pages/wallet/WalletView'; +import TokenOverview from '../../../e2e/pages/wallet/TokenOverview'; +import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; +import TestHelpers from '../../../e2e/helpers'; +import { RegressionTrade } from '../../../e2e/tags'; +import Assertions from '../../framework/Assertions'; +import ActivitiesView from '../../../e2e/pages/Transactions/ActivitiesView'; import { ActivitiesViewSelectorsText } from '../../../app/components/Views/ActivityView/ActivitiesView.testIds'; -import { submitSwapUnifiedUI } from './helpers/swap-unified-ui'; -import { testSpecificMock } from '../swaps/helpers/swap-mocks'; -import { prepareSwapsTestEnvironment } from './helpers/prepareSwapsTestEnvironment'; -import { AnvilPort } from '../../../tests/framework/fixtures/FixtureUtils'; -import { AnvilManager } from '../../../tests/seeder/anvil-manager'; +import { submitSwapUnifiedUI } from '../../helpers/swap/swap-unified-ui'; +import { testSpecificMock } from '../../helpers/swap/swap-mocks'; +import { prepareSwapsTestEnvironment } from '../../helpers/swap/prepareSwapsTestEnvironment'; +import { AnvilPort } from '../../framework/fixtures/FixtureUtils'; +import { AnvilManager } from '../../seeder/anvil-manager'; describe(RegressionTrade('Swap from Token view'), (): void => { jest.setTimeout(120000); diff --git a/tests/smoke/rewards/rewards.mocks.ts b/tests/smoke/rewards/rewards.mocks.ts deleted file mode 100644 index 605d5fe7769..00000000000 --- a/tests/smoke/rewards/rewards.mocks.ts +++ /dev/null @@ -1,876 +0,0 @@ -import { Mockttp } from 'mockttp'; -import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; -import { setupMockRequest } from '../../api-mocking/helpers/mockHelpers'; -import { DEFAULT_FIXTURE_ACCOUNT } from '../../framework/fixtures/FixtureBuilder'; - -const REWARDS_API_BASE_URL = - /https:\/\/rewards\.(uat|dev)-api\.cx\.metamask\.io/; -const SUBSCRIPTION_ID = '${SUBSCRIPTION_ID}'; - -const POINTS_EVENTS_AFTER_ONBOARDING = { - results: [ - { - id: `sb-${SUBSCRIPTION_ID}`, - timestamp: '2025-10-27T15:27:38.054Z', - type: 'SIGN_UP_BONUS', - value: 250, - bonus: {}, - accountAddress: DEFAULT_FIXTURE_ACCOUNT, - updatedAt: '2025-10-27T15:27:38.883Z', - }, - ], - has_more: false, - cursor: null, -}; - -const POINTS_EVENTS_MOCK = { - results: [ - { - id: `sb-${SUBSCRIPTION_ID}`, - timestamp: '2025-10-06T11:40:37.940Z', - type: 'SIGN_UP_BONUS', - value: 250, - bonus: {}, - accountAddress: DEFAULT_FIXTURE_ACCOUNT, - updatedAt: '2025-10-06T11:40:38.515Z', - payload: null, - }, - { - id: `swap-${SUBSCRIPTION_ID}-001`, - timestamp: '2025-10-06T12:15:22.123Z', - type: 'SWAP', - value: 10, - bonus: { - bips: 5000, - bonuses: ['mobile_boost'], - }, - accountAddress: DEFAULT_FIXTURE_ACCOUNT, - updatedAt: '2025-10-06T12:15:23.456Z', - payload: { - srcAsset: { - amount: '1000000000000000000', - type: 'eip155:1/slip44:60', - decimals: 18, - name: 'Ethereum', - symbol: 'ETH', - }, - destAsset: { - amount: '1000000000', - type: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', - decimals: 6, - name: 'USD Coin', - symbol: 'USDC', - }, - txHash: - '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', - }, - }, - { - id: `perps-${SUBSCRIPTION_ID}-001`, - timestamp: '2025-10-06T13:30:45.789Z', - type: 'PERPS', - value: 75, - bonus: { - bips: null, - bonuses: null, - }, - accountAddress: DEFAULT_FIXTURE_ACCOUNT, - updatedAt: '2025-10-06T13:30:46.012Z', - payload: { - type: 'OPEN_POSITION', - direction: 'LONG', - asset: { - amount: '500000000000000000', - type: 'eip155:42161/slip44:60', - decimals: 18, - name: 'Ethereum', - symbol: 'ETH', - }, - pnl: '12.5', - }, - }, - { - id: `referral-${SUBSCRIPTION_ID}-001`, - timestamp: '2025-10-06T14:45:12.345Z', - type: 'REFERRAL', - value: 100, - bonus: {}, - accountAddress: DEFAULT_FIXTURE_ACCOUNT, - updatedAt: '2025-10-06T14:45:13.678Z', - payload: null, - }, - { - id: `loyalty-${SUBSCRIPTION_ID}-001`, - timestamp: '2025-10-06T15:20:30.567Z', - type: 'LOYALTY_BONUS', - value: 50, - bonus: {}, - accountAddress: DEFAULT_FIXTURE_ACCOUNT, - updatedAt: '2025-10-06T15:20:31.890Z', - payload: null, - }, - { - id: `onetime-${SUBSCRIPTION_ID}-001`, - timestamp: '2025-10-06T16:10:15.234Z', - type: 'ONE_TIME_BONUS', - value: 300, - bonus: { - bips: 2000, - bonuses: ['special_event'], - }, - accountAddress: DEFAULT_FIXTURE_ACCOUNT, - updatedAt: '2025-10-06T16:10:16.567Z', - payload: null, - }, - { - id: `swap-${SUBSCRIPTION_ID}-002`, - timestamp: '2025-10-06T17:25:33.789Z', - type: 'SWAP', - value: 11, - bonus: { - bips: 990000, - bonuses: ['arbitrum_usdc_boost'], - }, - accountAddress: DEFAULT_FIXTURE_ACCOUNT, - updatedAt: '2025-10-06T17:25:34.123Z', - payload: { - srcAsset: { - amount: '2000000000000000000', - type: 'eip155:42161/slip44:60', - decimals: 18, - name: 'Ethereum', - symbol: 'ETH', - }, - destAsset: { - amount: '5000000000', - type: 'eip155:42161/erc20:0xaf88d065e77c8cc2239327c5edb3a432268e5831', - decimals: 6, - name: 'USD Coin', - symbol: 'USDC', - }, - txHash: - '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', - }, - }, - { - id: `swap-${SUBSCRIPTION_ID}-003`, - timestamp: '2025-10-06T18:45:12.456Z', - type: 'SWAP', - value: 5, - bonus: { - bips: null, - bonuses: null, - }, - accountAddress: DEFAULT_FIXTURE_ACCOUNT, - updatedAt: '2025-10-06T18:45:13.789Z', - payload: { - srcAsset: { - amount: '100000000', - type: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', - decimals: 6, - name: 'USD Coin', - symbol: 'USDC', - }, - destAsset: { - amount: '50000000000000000', - type: 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', - decimals: 18, - name: 'Dai Stablecoin', - symbol: 'DAI', - }, - txHash: - '0x9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba', - }, - }, - ], - has_more: true, - cursor: null, -}; - -const SEASON_STATUS_RESPONSE_ONBOARDING = { - current: { - id: 'f3f0ccae-33e9-4256-b5d9-c42105570fa5', - startDate: '2025-09-01T00:00:00.000Z', - endDate: '2026-03-31T00:00:00.000Z', - }, - next: null, -}; - -const SEASON_STATUS_RESPONSE = { - current: { - id: 'f3f0ccae-33e9-4256-b5d9-c42105570fa5', - name: 'Season 1', - startDate: '2025-09-01T04:00:00.000Z', - endDate: '2026-03-31T04:00:00.000Z', - tiers: [ - { - id: 'deb27e87-ed2c-4602-a4ba-3ed3de7fbe2b', - name: 'Origin', - pointsNeeded: 0, - seasonId: 'f3f0ccae-33e9-4256-b5d9-c42105570fa5', - image: { - darkModeUrl: - 'https://images.ctfassets.net/9sy2a0egs6zh/2E2VwVkQr8OPxgN9LxpucW/b4191e51486d817371e80244068636c9/level1-origin.png', - lightModeUrl: - 'https://images.ctfassets.net/9sy2a0egs6zh/2E2VwVkQr8OPxgN9LxpucW/b4191e51486d817371e80244068636c9/level1-origin.png', - }, - levelNumber: 'Level 1', - rewards: [], - }, - { - id: '0961380d-b62e-4f91-ab84-c2f9b90df3d8', - name: 'Frontier UAT', - pointsNeeded: 600, - seasonId: 'f3f0ccae-33e9-4256-b5d9-c42105570fa5', - image: { - darkModeUrl: - 'https://images.ctfassets.net/9sy2a0egs6zh/6XLXuclVLl32SDtMkxwmU2/81ffd73ba8ed3e6a541467f4f4f20c44/level2-frontier.png', - lightModeUrl: - 'https://images.ctfassets.net/9sy2a0egs6zh/6XLXuclVLl32SDtMkxwmU2/81ffd73ba8ed3e6a541467f4f4f20c44/level2-frontier.png', - }, - levelNumber: 'Level 2', - rewards: [ - { - id: '92964046-c81a-4c6f-a7d2-38ed7f2e8a78', - name: 'Qualify for $LINEA', - shortDescription: 'Based on your total points at end of the season', - longDescription: - 'Unlocking this reward qualifies you for a $LINEA token claim at the end of this season. Your total $LINEA reward will be based on your total points earned in the season vs points earned by all users participating. More points = more tokens!', - shortUnlockedDescription: 'More points = more tokens. Keep going!', - longUnlockedDescription: - 'You qualify for a $LINEA token claim at the end of this season! Keep earning points to get more $LINEA. At the end of the season, come back to collect your $LINEA. ', - iconName: 'Coin', - rewardType: 'GENERIC', - seasonTierId: '0961380d-b62e-4f91-ab84-c2f9b90df3d8', - createdAt: '2025-09-17T08:32:37.287Z', - updatedAt: '2025-09-17T08:32:37.287Z', - }, - ], - }, - { - id: '6438986a-fca9-4fa0-9226-7cc5810c4230', - name: 'Sylvana UAT', - pointsNeeded: 800, - seasonId: 'f3f0ccae-33e9-4256-b5d9-c42105570fa5', - image: { - darkModeUrl: - 'https://images.ctfassets.net/9sy2a0egs6zh/NtSEKGZcZ1iKYwghFZ1Hg/77101864677c7adb526bfc52844bc0fd/level3-sylvana.png', - lightModeUrl: - 'https://images.ctfassets.net/9sy2a0egs6zh/NtSEKGZcZ1iKYwghFZ1Hg/77101864677c7adb526bfc52844bc0fd/level3-sylvana.png', - }, - levelNumber: 'Level 3', - rewards: [ - { - id: 'b2416275-68ac-44c8-8e15-c72d36df6fb4', - name: '50% points boost', - shortDescription: 'Valid for 24 hours', - longDescription: - 'Unlock and claim this reward to get a 50% boost on all swaps and perps trades for the next 24 hours.', - shortUnlockedDescription: 'Valid for 24 hours', - longUnlockedDescription: - 'Claim this reward to get a 50% boost on all swaps and perps trades for the next 24 hours. The timer starts as soon as you claim it.', - iconName: 'Tag', - rewardType: 'POINTS_BOOST', - seasonTierId: '6438986a-fca9-4fa0-9226-7cc5810c4230', - createdAt: '2025-09-17T08:32:37.249Z', - updatedAt: '2025-09-17T18:30:37.690Z', - }, - ], - }, - { - id: 'e32e72c2-7e8a-4f47-9a72-9667a742d4bb', - name: 'Oceania UAT', - pointsNeeded: 1000, - seasonId: 'f3f0ccae-33e9-4256-b5d9-c42105570fa5', - image: { - darkModeUrl: - 'https://images.ctfassets.net/9sy2a0egs6zh/4crrZDPZCnhUit9hKsKpJQ/df43411458fee75a76b76afd375cc9e3/level4-oceania.png', - lightModeUrl: - 'https://images.ctfassets.net/9sy2a0egs6zh/4crrZDPZCnhUit9hKsKpJQ/df43411458fee75a76b76afd375cc9e3/level4-oceania.png', - }, - levelNumber: 'Level 4', - rewards: [ - { - id: 'eab7e0cb-79cd-40ae-8ede-1f318554a725', - name: '50% off Perps fees', - shortDescription: - 'Reduced fees for the season. Not available in all regions.', - longDescription: - 'Unlocking this reward will give you a 50% discount on MetaMask fees for perps trades throughout the rest of the season.\n\nNote: perps may not be available in some regions.', - shortUnlockedDescription: - 'Reduced fees for the season. Not available in all regions.', - longUnlockedDescription: - 'Congratulations, you have unlocked a 50% discount on MetaMask fees for perps trades. This discount is valid for the rest of the season. \n\nNote: perps may not be available in some regions.', - iconName: 'Tag', - rewardType: 'PERPS_DISCOUNT', - seasonTierId: 'e32e72c2-7e8a-4f47-9a72-9667a742d4bb', - createdAt: '2025-09-17T08:32:37.305Z', - updatedAt: '2025-09-22T13:18:48.652Z', - }, - ], - }, - { - id: '10ab8e2b-72ca-4e48-87f2-f51f6d467ffa', - name: 'Denalia UAT', - pointsNeeded: 1300, - seasonId: 'f3f0ccae-33e9-4256-b5d9-c42105570fa5', - image: { - darkModeUrl: - 'https://images.ctfassets.net/9sy2a0egs6zh/3dsWGbAaynPxd0BN0Ax1oR/2e41dd391daa03ce426dc9eacbd3e204/level5-denalia.png', - lightModeUrl: - 'https://images.ctfassets.net/9sy2a0egs6zh/3dsWGbAaynPxd0BN0Ax1oR/2e41dd391daa03ce426dc9eacbd3e204/level5-denalia.png', - }, - levelNumber: 'Level 5', - rewards: [ - { - id: '877adf22-14f9-449d-950d-d918baf197fe', - name: 'Priority support', - shortDescription: 'Get faster, dedicated support anytime', - longDescription: - 'Unlocking this reward will give you fast access to MetaMask support.', - shortUnlockedDescription: 'Get faster, dedicated support anytime', - longUnlockedDescription: - "Need support? You've unlocked fast access to MetaMask support. Get in touch with our team:", - claimUrl: 'https://support.metamask.io?address={address}', - iconName: 'Flash', - rewardType: 'GENERIC', - seasonTierId: '10ab8e2b-72ca-4e48-87f2-f51f6d467ffa', - createdAt: '2025-09-17T08:32:37.271Z', - updatedAt: '2025-09-17T18:30:37.710Z', - }, - ], - }, - { - id: '6712c53d-47bb-437a-9163-fd03aeda7c18', - name: 'Titana UAT', - pointsNeeded: 1600, - seasonId: 'f3f0ccae-33e9-4256-b5d9-c42105570fa5', - image: { - darkModeUrl: - 'https://images.ctfassets.net/9sy2a0egs6zh/72vEUZAPeUFdrbbb49QPUU/cd28022f5e947159ebec620011897144/level6-titana.png', - lightModeUrl: - 'https://images.ctfassets.net/9sy2a0egs6zh/72vEUZAPeUFdrbbb49QPUU/cd28022f5e947159ebec620011897144/level6-titana.png', - }, - levelNumber: 'Level 6', - rewards: [ - { - id: 'c4d23859-ee16-4d75-9278-1941838474ad', - name: 'MetaMask Metal Card', - shortDescription: - 'To be claimed at end of season. Not available in all regions.', - longDescription: - 'Unlocking this reward will qualify you for 1 free year of MetaMask Metal Card, to be claimed at the end of this season. \n\nNote: this reward is not available in all regions and is subject to approval.', - shortUnlockedDescription: - 'To be claimed at end of season. Not available in all regions.', - longUnlockedDescription: - 'Congratulations, you unlocked 1 free year of MetaMask Metal Card, to be claimed at the end of this season. \n\nNote: this reward is not available in all regions and is subject to approval.', - iconName: 'Card', - rewardType: 'GENERIC', - seasonTierId: '6712c53d-47bb-437a-9163-fd03aeda7c18', - createdAt: '2025-09-17T08:32:37.190Z', - updatedAt: '2025-09-17T08:32:37.190Z', - }, - { - id: 'a81e289d-8bf6-441d-9927-0e4a71eeedaf', - name: '65% off Perps fees', - shortDescription: - 'Reduced fees for the season. Not available in all regions.', - longDescription: - 'Unlocking this reward will give you a 65% discount on MetaMask fees for perps trades throughout the rest of the season. This discount replaces the 50% discount.\n\nNote: perps may not be available in some regions.', - shortUnlockedDescription: - 'Reduced fees for the season. Not available in all regions.', - longUnlockedDescription: - 'Congratulations, you have unlocked a 65% discount on MetaMask fees for perps trades. This discount is valid for the rest of the season. It replaces the 50% discount.\n\nNote: perps may not be available in some regions.', - iconName: 'Tag', - rewardType: 'PERPS_DISCOUNT', - seasonTierId: '6712c53d-47bb-437a-9163-fd03aeda7c18', - createdAt: '2025-09-17T08:32:37.169Z', - updatedAt: '2025-09-22T13:18:48.295Z', - }, - ], - }, - { - id: 'f44d417d-7526-47e9-afb3-cd9d0c625af7', - name: 'Utopia UAT', - pointsNeeded: 2000, - seasonId: 'f3f0ccae-33e9-4256-b5d9-c42105570fa5', - image: { - darkModeUrl: - 'https://images.ctfassets.net/9sy2a0egs6zh/gd1IVS0Q3rMJsEezYkGd1/a0ae03f51876518b27c0ecc165174ba4/level7-utopia.png', - lightModeUrl: - 'https://images.ctfassets.net/9sy2a0egs6zh/gd1IVS0Q3rMJsEezYkGd1/a0ae03f51876518b27c0ecc165174ba4/level7-utopia.png', - }, - levelNumber: 'Level 7', - rewards: [ - { - id: '52aa7be1-4905-46c2-a976-d1c7e6074555', - name: 'AlphaFoxes Telegram invite', - shortDescription: 'Get the alpha in our exclusive group', - longDescription: - 'Unlock this reward to get an exclusive invite to AlphaFoxes: a private Telegram group for top community members. AlphaFoxes get exclusive access to MetaMask leadership, sneak peeks of upcoming features, and alpha on crypto industry news.\n\nInvites are sent weekly.', - shortUnlockedDescription: 'Join the exclusive group', - longUnlockedDescription: - 'You’ve unlocked an invite to AlphaFoxes: a private Telegram group for top community members. Share your handle to get exclusive access to MetaMask leadership, sneak peeks of upcoming features, and alpha on crypto industry news.\n\nInvites are sent weekly.', - iconName: 'Sms', - rewardType: 'ALPHA_FOX_INVITE', - seasonTierId: 'f44d417d-7526-47e9-afb3-cd9d0c625af7', - createdAt: '2025-09-17T08:32:37.365Z', - updatedAt: '2025-09-17T18:30:37.901Z', - }, - ], - }, - ], - }, - balance: { - total: 1245, - refereePortion: 0, - updatedAt: '2025-10-04T04:58:50.025Z', - }, - currentTierId: 'e32e72c2-7e8a-4f47-9a72-9667a742d4bb', -}; - -const setupGeolocationMock = async (mockServer: Mockttp) => { - await setupMockRequest(mockServer, { - requestMethod: 'GET', - url: 'https://on-ramp.dev-api.cx.metamask.io/geolocation', - response: 'US', - responseCode: 200, - }); -}; - -const setupRewardsOisMock = async ( - mockServer: Mockttp, - isOnboarded = false, -) => { - await setupMockRequest(mockServer, { - requestMethod: 'POST', - url: new RegExp(`${REWARDS_API_BASE_URL.source}/public/rewards/ois`), - response: isOnboarded - ? { - ois: [true], - sids: [SUBSCRIPTION_ID], - } - : { - ois: [false], - sids: [null], - }, - responseCode: 200, - }); -}; - -const setupSeasonStatusMock = async ( - mockServer: Mockttp, - customResponse?: unknown, -) => { - await setupMockRequest(mockServer, { - requestMethod: 'GET', - url: new RegExp(`${REWARDS_API_BASE_URL.source}/public/seasons/status`), - response: customResponse || SEASON_STATUS_RESPONSE_ONBOARDING, - responseCode: 200, - }); -}; - -const setupActiveBoostsMock = async (mockServer: Mockttp) => { - await setupMockRequest(mockServer, { - requestMethod: 'GET', - url: new RegExp( - `${REWARDS_API_BASE_URL.source}/seasons/[a-f0-9-]+/active-boosts`, - ), - response: { - boosts: [ - { - id: 'b363dfeb-bceb-4d9c-a866-c05ee957178c', - name: '2x points for swaps on Linea', - icon: { - darkModeUrl: - 'https://images.ctfassets.net/9sy2a0egs6zh/7GQdzDwxvw6MmPFpWRGLy9/8bf4e190243303f6c1e88d5f0468e1c4/img.png', - lightModeUrl: - 'https://images.ctfassets.net/9sy2a0egs6zh/7GQdzDwxvw6MmPFpWRGLy9/8bf4e190243303f6c1e88d5f0468e1c4/img.png', - }, - boostBips: 10000, - seasonLong: true, - backgroundColor: '#190066', - }, - { - id: '9ae65872-1be3-4aa5-b46f-65be1aadcb78', - name: '50% points boost for swaps on mobile', - icon: { - darkModeUrl: - 'https://images.ctfassets.net/9sy2a0egs6zh/7GQdzDwxvw6MmPFpWRGLy9/8bf4e190243303f6c1e88d5f0468e1c4/img.png', - lightModeUrl: - 'https://images.ctfassets.net/9sy2a0egs6zh/7GQdzDwxvw6MmPFpWRGLy9/8bf4e190243303f6c1e88d5f0468e1c4/img.png', - }, - boostBips: 5000, - seasonLong: true, - backgroundColor: '#3D065F', - }, - ], - }, - responseCode: 200, - }); -}; - -const setupSeasonPointsEventsMock = async ( - mockServer: Mockttp, - customResponse?: unknown, -) => { - await setupMockRequest(mockServer, { - requestMethod: 'GET', - url: new RegExp( - `${REWARDS_API_BASE_URL.source}/seasons/[a-f0-9-]+/points-events`, - ), - responseCode: 200, - response: customResponse || POINTS_EVENTS_AFTER_ONBOARDING, - }); -}; - -const setupMobileOptinMock = async (mockServer: Mockttp) => { - await setupMockRequest(mockServer, { - requestMethod: 'POST', - url: new RegExp(`${REWARDS_API_BASE_URL.source}/auth/mobile-optin`), - response: { - sessionId: 'Oys8JRBJe3+UY9ghKDf8e99XcSyWGvTlV6wyPCG89HM=', - accessToken: 'Oys8JRBJe3+UY9ghKDf8e99XcSyWGvTlV6wyPCG89HM=', - subscription: { - id: `${SUBSCRIPTION_ID}`, - createdAt: '2025-10-06T11:40:37.929Z', - updatedAt: '2025-10-06T11:40:37.929Z', - referralCode: '2PCRJR', - accounts: [ - { - id: 1950, - address: DEFAULT_FIXTURE_ACCOUNT.toLowerCase(), - blockchain: 1, - subscriptionId: `${SUBSCRIPTION_ID}`, - createdAt: '2025-10-06T11:40:37.929Z', - updatedAt: '2025-10-06T11:40:37.929Z', - }, - ], - }, - }, - responseCode: 200, - }); -}; - -const setupMobileLoginMock = async (mockServer: Mockttp) => { - await setupMockRequest(mockServer, { - requestMethod: 'POST', - url: new RegExp(`${REWARDS_API_BASE_URL.source}/auth/mobile-login`), - response: { - sessionId: 'Oys8JRBJe3+UY9ghKDf8e99XcSyWGvTlV6wyPCG89HM=', - accessToken: 'Oys8JRBJe3+UY9ghKDf8e99XcSyWGvTlV6wyPCG89HM=', - subscription: { - id: `${SUBSCRIPTION_ID}`, - createdAt: '2025-10-06T11:40:37.929Z', - updatedAt: '2025-10-06T11:40:37.929Z', - referralCode: '2PCRJR', - accounts: [ - { - id: 1950, - address: DEFAULT_FIXTURE_ACCOUNT.toLowerCase(), - blockchain: 1, - subscriptionId: `${SUBSCRIPTION_ID}`, - createdAt: '2025-10-06T11:40:37.929Z', - updatedAt: '2025-10-06T11:40:37.929Z', - }, - ], - }, - }, - responseCode: 201, - }); -}; - -const setupMobileLoginUnauthorizedMock = async (mockServer: Mockttp) => { - await setupMockRequest(mockServer, { - requestMethod: 'POST', - url: new RegExp(`${REWARDS_API_BASE_URL.source}/auth/mobile-login`), - response: { error: 'Unauthorized' }, - responseCode: 401, - }); -}; - -const setupSeasonMetadataMock = async (mockServer: Mockttp) => { - await setupMockRequest(mockServer, { - requestMethod: 'GET', - url: new RegExp( - `${REWARDS_API_BASE_URL.source}/public/seasons/[a-f0-9-]+/meta`, - ), - response: { - id: 'f3f0ccae-33e9-4256-b5d9-c42105570fa5', - name: 'Season 1', - startDate: '2025-09-01T00:00:00.000Z', - endDate: '2026-03-31T00:00:00.000Z', - tiers: [ - { - id: '92d8b6a6-747c-45bf-8a91-6cdd5ac9f3bf', - name: 'Origin', - pointsNeeded: 0, - seasonId: 'f3f0ccae-33e9-4256-b5d9-c42105570fa5', - image: { - darkModeUrl: - 'https://images.ctfassets.net/9sy2a0egs6zh/4BfTdyjepPibM1moJExAns/34ff9efd1b4f409d53c38b8871f874f0/level4-foxland.png', - lightModeUrl: - 'https://images.ctfassets.net/9sy2a0egs6zh/4BfTdyjepPibM1moJExAns/34ff9efd1b4f409d53c38b8871f874f0/level4-foxland.png', - }, - levelNumber: 'Level 1', - rewards: [], - }, - { - id: 'a3f2309b-a0a9-42f1-904c-999246c56147', - name: 'Frontier', - pointsNeeded: 1000, - seasonId: 'f3f0ccae-33e9-4256-b5d9-c42105570fa5', - image: { - darkModeUrl: - 'https://images.ctfassets.net/9sy2a0egs6zh/4BfTdyjepPibM1moJExAns/34ff9efd1b4f409d53c38b8871f874f0/level4-foxland.png', - lightModeUrl: - 'https://images.ctfassets.net/9sy2a0egs6zh/4BfTdyjepPibM1moJExAns/34ff9efd1b4f409d53c38b8871f874f0/level4-foxland.png', - }, - levelNumber: 'Level 2', - rewards: [ - { - id: 'f9a281f5-500a-4c64-b2a5-2a828eb01463', - name: 'Qualify for $LINEA', - shortDescription: - 'Based on your total points at end of the season', - longDescription: - 'Unlocking this reward qualifies you for a $LINEA token claim at the end of this season. Your total $LINEA reward will be based on your total points earned in the season vs points earned by all users participating. More points = more tokens!', - shortUnlockedDescription: - 'More points = more tokens. Keep going!', - longUnlockedDescription: - 'You qualify for a $LINEA token claim at the end of this season! Keep earning points to get more $LINEA. At the end of the season, come back to collect your $LINEA. ', - iconName: 'Coin', - rewardType: 'GENERIC', - seasonTierId: 'a3f2309b-a0a9-42f1-904c-999246c56147', - createdAt: '2025-09-17T07:33:57.306Z', - updatedAt: '2025-09-17T07:48:32.518Z', - }, - ], - }, - { - id: '36cadfa5-5809-4355-83fb-c0693cec6155', - name: 'Sylvana', - pointsNeeded: 25000, - seasonId: 'f3f0ccae-33e9-4256-b5d9-c42105570fa5', - image: { - darkModeUrl: - 'https://images.ctfassets.net/9sy2a0egs6zh/4BfTdyjepPibM1moJExAns/34ff9efd1b4f409d53c38b8871f874f0/level4-foxland.png', - lightModeUrl: - 'https://images.ctfassets.net/9sy2a0egs6zh/4BfTdyjepPibM1moJExAns/34ff9efd1b4f409d53c38b8871f874f0/level4-foxland.png', - }, - levelNumber: 'Level 3', - rewards: [ - { - id: '3d697982-7e38-443d-9b63-079104a8f4c5', - name: '50% points boost', - shortDescription: 'Valid for 24 hours', - longDescription: - 'Unlock and claim this reward to get a 50% boost on all swaps and perps trades for the next 24 hours.', - shortUnlockedDescription: 'Valid for 24 hours', - longUnlockedDescription: - 'Claim this reward to get a 50% boost on all swaps and perps trades for the next 24 hours. The timer starts as soon as you claim it.', - iconName: 'Tag', - rewardType: 'POINTS_BOOST', - seasonTierId: '36cadfa5-5809-4355-83fb-c0693cec6155', - createdAt: '2025-09-17T07:48:32.622Z', - updatedAt: '2025-09-17T18:29:33.872Z', - }, - ], - }, - { - id: '908b8c49-f576-40a9-8c1d-b2650f4023b7', - name: 'Oceania', - pointsNeeded: 50000, - seasonId: 'f3f0ccae-33e9-4256-b5d9-c42105570fa5', - image: { - darkModeUrl: - 'https://images.ctfassets.net/9sy2a0egs6zh/4BfTdyjepPibM1moJExAns/34ff9efd1b4f409d53c38b8871f874f0/level4-foxland.png', - lightModeUrl: - 'https://images.ctfassets.net/9sy2a0egs6zh/4BfTdyjepPibM1moJExAns/34ff9efd1b4f409d53c38b8871f874f0/level4-foxland.png', - }, - levelNumber: 'Level 4', - rewards: [ - { - id: '5dfa42ab-58b9-4c53-b72f-7cf63e200284', - name: '50% off Perps fees', - shortDescription: - 'Reduced fees for the season. Not available in all regions.', - longDescription: - 'Unlocking this reward will give you a 50% discount on fees for perps trades throughout the rest of the season.\n\nNote: perps may not be available in some regions.', - shortUnlockedDescription: - 'Reduced fees for the season. Not available in all regions.', - longUnlockedDescription: - 'Congratulations, you have unlocked a 50% discount on fees for perps trades. This discount is valid for the rest of the season. \n\nNote: perps may not be available in some regions.', - iconName: 'Tag', - rewardType: 'PERPS_DISCOUNT', - seasonTierId: '908b8c49-f576-40a9-8c1d-b2650f4023b7', - createdAt: '2025-09-17T07:33:57.306Z', - updatedAt: '2025-09-17T07:48:32.634Z', - }, - ], - }, - { - id: 'a8733377-04c4-4276-9787-e8e1c76eead8', - name: 'Denalia', - pointsNeeded: 100000, - seasonId: 'f3f0ccae-33e9-4256-b5d9-c42105570fa5', - image: { - darkModeUrl: - 'https://images.ctfassets.net/9sy2a0egs6zh/4BfTdyjepPibM1moJExAns/34ff9efd1b4f409d53c38b8871f874f0/level4-foxland.png', - lightModeUrl: - 'https://images.ctfassets.net/9sy2a0egs6zh/4BfTdyjepPibM1moJExAns/34ff9efd1b4f409d53c38b8871f874f0/level4-foxland.png', - }, - levelNumber: 'Level 5', - rewards: [ - { - id: 'fb048001-99ff-48a6-bfa7-f39115e49468', - name: 'Priority support', - shortDescription: 'Get faster, dedicated support anytime', - longDescription: - 'Unlocking this reward will give you fast access to MetaMask support.', - shortUnlockedDescription: 'Get faster, dedicated support anytime', - longUnlockedDescription: - "Need support? You've unlocked fast access to MetaMask support. Get in touch with our team:", - claimUrl: 'https://support.metamask.io?address={address}', - iconName: 'Flash', - rewardType: 'GENERIC', - seasonTierId: 'a8733377-04c4-4276-9787-e8e1c76eead8', - createdAt: '2025-09-17T07:33:57.306Z', - updatedAt: '2025-09-17T18:29:33.965Z', - }, - ], - }, - { - id: '9d095df1-56fe-4fd1-9e27-558e65ec0af5', - name: 'Titana', - pointsNeeded: 500000, - seasonId: 'f3f0ccae-33e9-4256-b5d9-c42105570fa5', - image: { - darkModeUrl: - 'https://images.ctfassets.net/9sy2a0egs6zh/4BfTdyjepPibM1moJExAns/34ff9efd1b4f409d53c38b8871f874f0/level4-foxland.png', - lightModeUrl: - 'https://images.ctfassets.net/9sy2a0egs6zh/4BfTdyjepPibM1moJExAns/34ff9efd1b4f409d53c38b8871f874f0/level4-foxland.png', - }, - levelNumber: 'Level 6', - rewards: [ - { - id: '65c52ea3-e886-4eb1-b9eb-62bf4c564591', - name: '65% off Perps fees', - shortDescription: - 'Reduced fees for the season. Not available in all regions.', - longDescription: - 'Unlocking this reward will give you a 65% discount on fees for perps trades throughout the rest of the season. This discount replaces the 50% discount.\n\nNote: perps may not be available in some regions.', - shortUnlockedDescription: - 'Reduced fees for the season. Not available in all regions.', - longUnlockedDescription: - 'Congratulations, you have unlocked a 65% discount on fees for perps trades. This discount is valid for the rest of the season. It replaces the 50% discount.\n\nNote: perps may not be available in some regions.', - iconName: 'Tag', - rewardType: 'PERPS_DISCOUNT', - seasonTierId: '9d095df1-56fe-4fd1-9e27-558e65ec0af5', - createdAt: '2025-09-17T07:33:57.306Z', - updatedAt: '2025-09-17T07:48:32.648Z', - }, - { - id: '6ed2799c-85ca-4123-b1ce-d6e9fccfa120', - name: 'MetaMask Metal Card', - shortDescription: - 'To be claimed at end of season. Not available in all regions.', - longDescription: - 'Unlocking this reward will qualify you for 1 free year of MetaMask Metal Card, to be claimed at the end of this season. \n\nNote: this reward is not available in all regions and is subject to approval.', - shortUnlockedDescription: - 'To be claimed at end of season. Not available in all regions.', - longUnlockedDescription: - 'Congratulations, you unlocked 1 free year of MetaMask Metal Card, to be claimed at the end of this season. \n\nNote: this reward is not available in all regions and is subject to approval.', - iconName: 'Card', - rewardType: 'GENERIC', - seasonTierId: '9d095df1-56fe-4fd1-9e27-558e65ec0af5', - createdAt: '2025-09-17T07:33:57.306Z', - updatedAt: '2025-09-17T07:48:32.654Z', - }, - ], - }, - { - id: '55698773-a84f-425c-9278-d3e6a4b25770', - name: 'Utopia', - pointsNeeded: 1000000, - seasonId: 'f3f0ccae-33e9-4256-b5d9-c42105570fa5', - image: { - darkModeUrl: - 'https://images.ctfassets.net/9sy2a0egs6zh/4BfTdyjepPibM1moJExAns/34ff9efd1b4f409d53c38b8871f874f0/level4-foxland.png', - lightModeUrl: - 'https://images.ctfassets.net/9sy2a0egs6zh/4BfTdyjepPibM1moJExAns/34ff9efd1b4f409d53c38b8871f874f0/level4-foxland.png', - }, - levelNumber: 'Level 7', - rewards: [ - { - id: '2131c7e4-afbc-4ab6-8f69-02edfc9f8428', - name: 'AlphaFoxes Telegram invite', - shortDescription: 'Get the alpha in our exclusive group', - longDescription: - 'Unlock this reward to get an exclusive invite to AlphaFoxes: a private Telegram group for top community members. AlphaFoxes get exclusive access to MetaMask leadership, sneak peeks of upcoming features, and alpha on crypto industry news.\n\nInvites are sent weekly.', - shortUnlockedDescription: 'Join the exclusive group', - longUnlockedDescription: - "You've unlocked an invite to AlphaFoxes: a private Telegram group for top community members. Share your handle to get exclusive access to MetaMask leadership, sneak peeks of upcoming features, and alpha on crypto industry news.\n\nInvites are sent weekly.", - iconName: 'Sms', - rewardType: 'ALPHA_FOX_INVITE', - seasonTierId: '55698773-a84f-425c-9278-d3e6a4b25770', - createdAt: '2025-09-17T07:48:32.666Z', - updatedAt: '2025-09-17T18:29:34.079Z', - }, - ], - }, - ], - }, - responseCode: 200, - }); -}; - -const setupSeasonStateMock = async ( - mockServer: Mockttp, - balance = 250, - currentTierId = '92d8b6a6-747c-45bf-8a91-6cdd5ac9f3bf', -) => { - await setupMockRequest(mockServer, { - requestMethod: 'GET', - url: new RegExp(`${REWARDS_API_BASE_URL.source}/seasons/[a-f0-9-]+/state`), - response: { - balance, - currentTierId, - updatedAt: new Date().toISOString(), - }, - responseCode: 200, - }); -}; - -export const setUpRewardsOnboardingMocks = async (mockServer: Mockttp) => { - await setupRemoteFeatureFlagsMock(mockServer, { - rewardsEnabled: { enabled: true, minimumVersion: '7.57.0' }, - }); - await setupGeolocationMock(mockServer); - await setupMobileLoginUnauthorizedMock(mockServer); - await setupRewardsOisMock(mockServer); - await setupMobileOptinMock(mockServer); - await setupSeasonStatusMock(mockServer); - await setupSeasonMetadataMock(mockServer); - await setupSeasonStateMock(mockServer); - await setupActiveBoostsMock(mockServer); - await setupSeasonPointsEventsMock(mockServer, POINTS_EVENTS_AFTER_ONBOARDING); -}; - -export const setUpActivityMocks = async (mockServer: Mockttp) => { - await setupRemoteFeatureFlagsMock(mockServer, { - rewardsEnabled: { enabled: true, minimumVersion: '7.57.0' }, - }); - await setupGeolocationMock(mockServer); - await setupRewardsOisMock(mockServer, true); - await setupMobileLoginMock(mockServer); - await setupSeasonMetadataMock(mockServer); - await setupSeasonStateMock( - mockServer, - 52000, - '908b8c49-f576-40a9-8c1d-b2650f4023b7', - ); - await setupSeasonStatusMock(mockServer, SEASON_STATUS_RESPONSE); - await setupSeasonPointsEventsMock(mockServer, POINTS_EVENTS_MOCK); - await setupActiveBoostsMock(mockServer); -}; diff --git a/tests/smoke/rewards/rewards.spec.ts b/tests/smoke/rewards/rewards.spec.ts deleted file mode 100644 index 69ce4c66bbf..00000000000 --- a/tests/smoke/rewards/rewards.spec.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { Mockttp } from 'mockttp'; -import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; -import { withFixtures } from '../../framework/fixtures/FixtureHelper'; -import TabBarComponent from '../../../e2e/pages/wallet/TabBarComponent'; -import { SmokeRewards } from '../../../e2e/tags'; -import { loginToApp } from '../../../e2e/viewHelper'; -import RewardsClaimBonus from '../../../e2e/pages/Rewards/RewardsOnboarding'; -import Assertions from '../../framework/Assertions'; -import RewardsView from '../../../e2e/pages/Rewards/RewardsView'; -import RewardsActivityTabView from '../../../e2e/pages/Rewards/RewardsActivityTabView'; -import { - setUpActivityMocks, - setUpRewardsOnboardingMocks, -} from './rewards.mocks'; - -describe.skip(SmokeRewards('Rewards Feature Test'), () => { - it('should opt-in to rewards successfully', async () => { - const testSpecificMock = async (mockServer: Mockttp) => { - await setUpRewardsOnboardingMocks(mockServer); - }; - await withFixtures( - { - fixture: new FixtureBuilder().build(), - restartDevice: true, - testSpecificMock, - }, - async () => { - await device.disableSynchronization(); - await loginToApp(); - await TabBarComponent.tapRewards(); - await RewardsClaimBonus.tapRewardsClaimBonusButton(); - await RewardsClaimBonus.existsRewardsOnboardingInfoScreen1(); - await RewardsClaimBonus.tapRewardsOnboardingNextButton(); - await RewardsClaimBonus.existsRewardsOnboardingInfoScreen2(); - await RewardsClaimBonus.tapRewardsOnboardingNextButton(); - await RewardsClaimBonus.existsRewardsOnboardingInfoScreen3(); - await RewardsClaimBonus.tapRewardsOnboardingNextButton(); - await RewardsClaimBonus.existsRewardsOnboardingInfoScreen4(); - await RewardsClaimBonus.tapRewardsOnboardingNextButton(); - await Assertions.expectElementToBeVisible( - RewardsView.rewardsOverviewTabButton, - { - elemDescription: 'Rewards Overview Tab Button', - }, - ); - await RewardsView.tapRewardActivityTabButton(); - await Assertions.expectElementToHaveText( - RewardsActivityTabView.signUpBonusTitle, - 'Sign up bonus', - { - description: 'Sign up bonus title should have correct text', - }, - ); - await Assertions.expectElementToHaveText( - RewardsActivityTabView.signUpBonusValue, - '+250', - { - description: 'Sign up bonus value should have correct text', - }, - ); - await Assertions.expectElementToHaveText( - RewardsView.seasonStatusLevel, - 'Level 1', - { - description: 'Season status level should have correct text', - }, - ); - await Assertions.expectElementToHaveText( - RewardsView.seasonStatusTierName, - 'Origin', - { - description: 'Season status tier name should have correct text', - }, - ); - await Assertions.expectElementToHaveText( - RewardsView.seasonStatusPoints, - '250', - { - description: 'Season status points should have correct text', - }, - ); - }, - ); - }); - - it('should display activity details inactivity tab successfully', async () => { - const testSpecificMock = async (mockServer: Mockttp) => { - await setUpActivityMocks(mockServer); - }; - await withFixtures( - { - fixture: new FixtureBuilder().build(), - restartDevice: true, - testSpecificMock, - }, - async () => { - await device.disableSynchronization(); - await loginToApp(); - await TabBarComponent.tapRewards(); - await RewardsView.tapRewardActivityTabButton(); - - await Assertions.expectElementToHaveText( - RewardsView.seasonStatusLevel, - 'Level 4', - { - description: 'Season status level should have correct text', - }, - ); - await Assertions.expectElementToHaveText( - RewardsView.seasonStatusTierName, - 'Oceania', - { - description: 'Season status tier name should have correct text', - }, - ); - await Assertions.expectElementToHaveText( - RewardsView.seasonStatusPoints, - '52,000', - { - description: 'Season status points should have correct text', - }, - ); - - // Verify specific data in each row using page object - await Assertions.expectElementToHaveText( - RewardsActivityTabView.signUpBonusTitle, - 'Sign up bonus', - { - description: 'Sign up bonus title should have correct text', - }, - ); - await Assertions.expectElementToHaveText( - RewardsActivityTabView.signUpBonusValue, - '+250', - { - description: 'Sign up bonus value should have correct text', - }, - ); - - await Assertions.expectElementToHaveText( - RewardsActivityTabView.perpsTitle, - 'Opened position', - { - description: 'Perps title should have correct text', - }, - ); - await Assertions.expectElementToHaveText( - RewardsActivityTabView.perpsValue, - '+75', - { - description: 'Perps value should have correct text', - }, - ); - await Assertions.expectElementToHaveText( - RewardsActivityTabView.perpsDetails, - 'Long 0.5 ETH', - { - description: 'Perps details should have correct text', - }, - ); - - await Assertions.expectElementToHaveText( - RewardsActivityTabView.firstSwapTitle, - 'Swap', - { - description: 'First swap title should have correct text', - }, - ); - await Assertions.expectElementToHaveText( - RewardsActivityTabView.firstSwapValue, - '+10', - { - description: 'First swap value should have correct text', - }, - ); - await Assertions.expectElementToHaveText( - RewardsActivityTabView.firstSwapDetails, - '1 ETH to USDC', - { - description: 'First swap details should have correct text', - }, - ); - - await Assertions.expectElementToHaveText( - RewardsActivityTabView.referralTitle, - 'Referral action', - { - description: 'Referral title should have correct text', - }, - ); - await Assertions.expectElementToHaveText( - RewardsActivityTabView.referralValue, - '+100', - { - description: 'Referral value should have correct text', - }, - ); - }, - ); - }); -}); diff --git a/e2e/specs/swaps/bridge-action-smoke.spec.ts b/tests/smoke/swap/bridge-action-smoke.spec.ts similarity index 91% rename from e2e/specs/swaps/bridge-action-smoke.spec.ts rename to tests/smoke/swap/bridge-action-smoke.spec.ts index 1501ed4a9fd..a5e24f45c75 100644 --- a/e2e/specs/swaps/bridge-action-smoke.spec.ts +++ b/tests/smoke/swap/bridge-action-smoke.spec.ts @@ -1,19 +1,19 @@ -import { withFixtures } from '../../../tests/framework/fixtures/FixtureHelper'; -import { LocalNode, LocalNodeType } from '../../../tests/framework/types'; -import { loginToApp } from '../../viewHelper'; -import TabBarComponent from '../../pages/wallet/TabBarComponent'; -import QuoteView from '../../pages/swaps/QuoteView'; -import FixtureBuilder from '../../../tests/framework/fixtures/FixtureBuilder'; -import WalletView from '../../pages/wallet/WalletView'; -import TestHelpers from '../../helpers'; -import { SmokeTrade } from '../../tags'; -import Assertions from '../../../tests/framework/Assertions'; -import ActivitiesView from '../../pages/Transactions/ActivitiesView'; -import { prepareSwapsTestEnvironment } from './helpers/prepareSwapsTestEnvironment'; -import { testSpecificMock } from './helpers/bridge-mocks'; -import SoftAssert from '../../../tests/framework/SoftAssert'; -import { AnvilPort } from '../../../tests/framework/fixtures/FixtureUtils'; -import { AnvilManager } from '../../../tests/seeder/anvil-manager'; +import { withFixtures } from '../../framework/fixtures/FixtureHelper'; +import { LocalNode, LocalNodeType } from '../../framework/types'; +import { loginToApp } from '../../../e2e/viewHelper'; +import TabBarComponent from '../../../e2e/pages/wallet/TabBarComponent'; +import QuoteView from '../../../e2e/pages/swaps/QuoteView'; +import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; +import WalletView from '../../../e2e/pages/wallet/WalletView'; +import TestHelpers from '../../../e2e/helpers'; +import { SmokeTrade } from '../../../e2e/tags'; +import Assertions from '../../framework/Assertions'; +import ActivitiesView from '../../../e2e/pages/Transactions/ActivitiesView'; +import { prepareSwapsTestEnvironment } from '../../helpers/swap/prepareSwapsTestEnvironment'; +import { testSpecificMock } from '../../helpers/swap/bridge-mocks'; +import SoftAssert from '../../framework/SoftAssert'; +import { AnvilPort } from '../../framework/fixtures/FixtureUtils'; +import { AnvilManager } from '../../seeder/anvil-manager'; enum eventsToCheck { BRIDGE_BUTTON_CLICKED = 'Bridge Button Clicked', diff --git a/e2e/specs/swaps/gasless-swap.spec.ts b/tests/smoke/swap/gasless-swap.spec.ts similarity index 75% rename from e2e/specs/swaps/gasless-swap.spec.ts rename to tests/smoke/swap/gasless-swap.spec.ts index 0107e982a42..133f2ecdfea 100644 --- a/e2e/specs/swaps/gasless-swap.spec.ts +++ b/tests/smoke/swap/gasless-swap.spec.ts @@ -1,16 +1,16 @@ -import { withFixtures } from '../../../tests/framework/fixtures/FixtureHelper'; -import { LocalNode, LocalNodeType } from '../../../tests/framework/types'; -import FixtureBuilder from '../../../tests/framework/fixtures/FixtureBuilder'; -import Assertions from '../../../tests/framework/Assertions'; -import WalletView from '../../pages/wallet/WalletView'; -import { SmokeTrade } from '../../tags'; -import { loginToApp } from '../../viewHelper'; -import { logger } from '../../../tests/framework/logger'; -import { AnvilPort } from '../../../tests/framework/fixtures/FixtureUtils'; -import { AnvilManager } from '../../../tests/seeder/anvil-manager'; -import QuoteView from '../../pages/swaps/QuoteView'; -import { setupMockRequest } from '../../../tests/api-mocking/helpers/mockHelpers'; -import { GASLESS_SWAP_QUOTES_ETH_MUSD } from './helpers/constants'; +import { withFixtures } from '../../framework/fixtures/FixtureHelper'; +import { LocalNode, LocalNodeType } from '../../framework/types'; +import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; +import Assertions from '../../framework/Assertions'; +import WalletView from '../../../e2e/pages/wallet/WalletView'; +import { SmokeTrade } from '../../../e2e/tags'; +import { loginToApp } from '../../../e2e/viewHelper'; +import { logger } from '../../framework/logger'; +import { AnvilPort } from '../../framework/fixtures/FixtureUtils'; +import { AnvilManager } from '../../seeder/anvil-manager'; +import QuoteView from '../../../e2e/pages/swaps/QuoteView'; +import { setupMockRequest } from '../../api-mocking/helpers/mockHelpers'; +import { GASLESS_SWAP_QUOTES_ETH_MUSD } from '../../helpers/swap/constants'; describe(SmokeTrade('Gasless Swap - '), (): void => { const chainId = '0x1'; diff --git a/e2e/specs/swaps/swap-action-smoke.spec.ts b/tests/smoke/swap/swap-action-smoke.spec.ts similarity index 91% rename from e2e/specs/swaps/swap-action-smoke.spec.ts rename to tests/smoke/swap/swap-action-smoke.spec.ts index e3fda4053cc..4c672053ae5 100644 --- a/e2e/specs/swaps/swap-action-smoke.spec.ts +++ b/tests/smoke/swap/swap-action-smoke.spec.ts @@ -1,23 +1,23 @@ -import { withFixtures } from '../../../tests/framework/fixtures/FixtureHelper'; -import { LocalNode, LocalNodeType } from '../../../tests/framework/types'; -import SoftAssert from '../../../tests/framework/SoftAssert'; -import FixtureBuilder from '../../../tests/framework/fixtures/FixtureBuilder'; -import Assertions from '../../../tests/framework/Assertions'; -import WalletView from '../../pages/wallet/WalletView'; -import { SmokeTrade } from '../../tags'; -import ActivitiesView from '../../pages/Transactions/ActivitiesView'; +import { withFixtures } from '../../framework/fixtures/FixtureHelper'; +import { LocalNode, LocalNodeType } from '../../framework/types'; +import SoftAssert from '../../framework/SoftAssert'; +import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; +import Assertions from '../../framework/Assertions'; +import WalletView from '../../../e2e/pages/wallet/WalletView'; +import { SmokeTrade } from '../../../e2e/tags'; +import ActivitiesView from '../../../e2e/pages/Transactions/ActivitiesView'; import { ActivitiesViewSelectorsText } from '../../../app/components/Views/ActivityView/ActivitiesView.testIds'; import { EventPayload, getEventsPayloads, -} from '../../../tests/helpers/analytics/helpers'; -import { submitSwapUnifiedUI } from './helpers/swap-unified-ui'; -import { loginToApp } from '../../viewHelper'; -import { prepareSwapsTestEnvironment } from './helpers/prepareSwapsTestEnvironment'; -import { logger } from '../../../tests/framework/logger'; -import { testSpecificMock } from './helpers/swap-mocks'; -import { AnvilPort } from '../../../tests/framework/fixtures/FixtureUtils'; -import { AnvilManager } from '../../../tests/seeder/anvil-manager'; +} from '../../helpers/analytics/helpers'; +import { submitSwapUnifiedUI } from '../../helpers/swap/swap-unified-ui'; +import { loginToApp } from '../../../e2e/viewHelper'; +import { prepareSwapsTestEnvironment } from '../../helpers/swap/prepareSwapsTestEnvironment'; +import { logger } from '../../framework/logger'; +import { testSpecificMock } from '../../helpers/swap/swap-mocks'; +import { AnvilPort } from '../../framework/fixtures/FixtureUtils'; +import { AnvilManager } from '../../seeder/anvil-manager'; const EVENT_NAMES = { SWAP_STARTED: 'Swap Started', diff --git a/e2e/specs/swaps/swap-deeplink-smoke.spec.ts b/tests/smoke/swap/swap-deeplink-smoke.spec.ts similarity index 90% rename from e2e/specs/swaps/swap-deeplink-smoke.spec.ts rename to tests/smoke/swap/swap-deeplink-smoke.spec.ts index d8f7f5a405f..8c84944992e 100644 --- a/e2e/specs/swaps/swap-deeplink-smoke.spec.ts +++ b/tests/smoke/swap/swap-deeplink-smoke.spec.ts @@ -1,17 +1,17 @@ 'use strict'; /* eslint-disable no-console */ -import { withFixtures } from '../../../tests/framework/fixtures/FixtureHelper'; -import { LocalNode, LocalNodeType } from '../../../tests/framework/types'; -import { loginToApp } from '../../viewHelper'; -import FixtureBuilder from '../../../tests/framework/fixtures/FixtureBuilder'; -import { AnvilManager } from '../../../tests/seeder/anvil-manager'; -import { AnvilPort } from '../../../tests/framework/fixtures/FixtureUtils'; -import { SmokeTrade } from '../../tags'; -import Assertions from '../../../tests/framework/Assertions'; -import QuoteView from '../../pages/swaps/QuoteView'; -import { testSpecificMock } from './helpers/swap-mocks'; -import WalletView from '../../pages/wallet/WalletView'; -import DeeplinkModal from '../../pages/swaps/Deeplink'; +import { withFixtures } from '../../framework/fixtures/FixtureHelper'; +import { LocalNode, LocalNodeType } from '../../framework/types'; +import { loginToApp } from '../../../e2e/viewHelper'; +import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; +import { AnvilManager } from '../../seeder/anvil-manager'; +import { AnvilPort } from '../../framework/fixtures/FixtureUtils'; +import { SmokeTrade } from '../../../e2e/tags'; +import Assertions from '../../framework/Assertions'; +import QuoteView from '../../../e2e/pages/swaps/QuoteView'; +import { testSpecificMock } from '../../helpers/swap/swap-mocks'; +import WalletView from '../../../e2e/pages/wallet/WalletView'; +import DeeplinkModal from '../../../e2e/pages/swaps/Deeplink'; // Deep link URLs for testing unified swap/bridge experience // Note: URLs use 'swap' terminology for backward compatibility but redirect to unified bridge experience diff --git a/e2e/specs/swaps/unified-ui-wallet-actions.spec.ts b/tests/smoke/swap/unified-ui-wallet-actions.spec.ts similarity index 76% rename from e2e/specs/swaps/unified-ui-wallet-actions.spec.ts rename to tests/smoke/swap/unified-ui-wallet-actions.spec.ts index 1a5290c4189..7df37e52a4c 100644 --- a/e2e/specs/swaps/unified-ui-wallet-actions.spec.ts +++ b/tests/smoke/swap/unified-ui-wallet-actions.spec.ts @@ -1,14 +1,14 @@ 'use strict'; import { Mockttp } from 'mockttp'; -import { loginToApp } from '../../viewHelper'; -import TabBarComponent from '../../pages/wallet/TabBarComponent'; -import WalletActionsBottomSheet from '../../pages/wallet/WalletActionsBottomSheet'; -import FixtureBuilder from '../../../tests/framework/fixtures/FixtureBuilder'; -import { withFixtures } from '../../../tests/framework/fixtures/FixtureHelper'; -import { SmokeTrade } from '../../tags.js'; -import WalletView from '../../pages/wallet/WalletView'; -import Assertions from '../../../tests/framework/Assertions'; -import TestHelpers from '../../helpers.js'; +import { loginToApp } from '../../../e2e/viewHelper'; +import TabBarComponent from '../../../e2e/pages/wallet/TabBarComponent'; +import WalletActionsBottomSheet from '../../../e2e/pages/wallet/WalletActionsBottomSheet'; +import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; +import { withFixtures } from '../../framework/fixtures/FixtureHelper'; +import { SmokeTrade } from '../../../e2e/tags.js'; +import WalletView from '../../../e2e/pages/wallet/WalletView'; +import Assertions from '../../framework/Assertions'; +import TestHelpers from '../../../e2e/helpers.js'; describe(SmokeTrade('Trade: Unified UI Wallet Actions'), () => { let mockServer: Mockttp | undefined; diff --git a/e2e/specs/trending/trending-browser.spec.ts b/tests/smoke/trending/trending-browser.spec.ts similarity index 59% rename from e2e/specs/trending/trending-browser.spec.ts rename to tests/smoke/trending/trending-browser.spec.ts index d3a7311043d..b3b6d2bd341 100644 --- a/e2e/specs/trending/trending-browser.spec.ts +++ b/tests/smoke/trending/trending-browser.spec.ts @@ -1,16 +1,16 @@ -import { SmokeWalletPlatform } from '../../tags'; -import { loginToApp, navigateToBrowserView } from '../../viewHelper'; -import FixtureBuilder from '../../../tests/framework/fixtures/FixtureBuilder'; -import { withFixtures } from '../../../tests/framework/fixtures/FixtureHelper'; -import { DappVariants } from '../../../tests/framework/Constants'; +import { SmokeWalletPlatform } from '../../../e2e/tags'; +import { loginToApp, navigateToBrowserView } from '../../../e2e/viewHelper'; +import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; +import { withFixtures } from '../../framework/fixtures/FixtureHelper'; +import { DappVariants } from '../../framework/Constants'; import { Mockttp } from 'mockttp'; -import { Assertions } from '../../../tests/framework'; -import { setupRemoteFeatureFlagsMock } from '../../../tests/api-mocking/helpers/remoteFeatureFlagsHelper'; -import { TRENDING_API_MOCKS } from '../../../tests/api-mocking/mock-responses/trending-api-mocks'; -import { setupMockEvents } from '../../../tests/api-mocking/helpers/mockHelpers'; -import { remoteFeatureFlagTrendingTokensEnabled } from '../../../tests/api-mocking/mock-responses/feature-flags-mocks'; -import Browser from '../../pages/Browser/BrowserView'; -import TestDApp from '../../pages/Browser/TestDApp'; +import { Assertions } from '../../framework'; +import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; +import { TRENDING_API_MOCKS } from '../../api-mocking/mock-responses/trending-api-mocks'; +import { setupMockEvents } from '../../api-mocking/helpers/mockHelpers'; +import { remoteFeatureFlagTrendingTokensEnabled } from '../../api-mocking/mock-responses/feature-flags-mocks'; +import Browser from '../../../e2e/pages/Browser/BrowserView'; +import TestDApp from '../../../e2e/pages/Browser/TestDApp'; describe(SmokeWalletPlatform('Trending Feature Browser Test'), () => { const testSpecificMock = async (mockServer: Mockttp) => { diff --git a/e2e/specs/trending/trending-feed.spec.ts b/tests/smoke/trending/trending-feed.spec.ts similarity index 86% rename from e2e/specs/trending/trending-feed.spec.ts rename to tests/smoke/trending/trending-feed.spec.ts index cf7d8a9a266..8769e0a9cdd 100644 --- a/e2e/specs/trending/trending-feed.spec.ts +++ b/tests/smoke/trending/trending-feed.spec.ts @@ -1,17 +1,17 @@ -import { SmokeWalletPlatform } from '../../tags'; -import { loginToApp } from '../../viewHelper'; -import FixtureBuilder from '../../../tests/framework/fixtures/FixtureBuilder'; -import { withFixtures } from '../../../tests/framework/fixtures/FixtureHelper'; -import { setupRemoteFeatureFlagsMock } from '../../../tests/api-mocking/helpers/remoteFeatureFlagsHelper'; +import { SmokeWalletPlatform } from '../../../e2e/tags'; +import { loginToApp } from '../../../e2e/viewHelper'; +import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; +import { withFixtures } from '../../framework/fixtures/FixtureHelper'; +import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; import { Mockttp } from 'mockttp'; -import TrendingView from '../../../tests/page-objects/Trending/TrendingView'; -import { TrendingViewSelectorsText } from '../../../tests/locators/Trending/TrendingView.selectors'; -import { TRENDING_API_MOCKS } from '../../../tests/api-mocking/mock-responses/trending-api-mocks'; -import { setupMockEvents } from '../../../tests/api-mocking/helpers/mockHelpers'; +import TrendingView from '../../page-objects/Trending/TrendingView'; +import { TrendingViewSelectorsText } from '../../locators/Trending/TrendingView.selectors'; +import { TRENDING_API_MOCKS } from '../../api-mocking/mock-responses/trending-api-mocks'; +import { setupMockEvents } from '../../api-mocking/helpers/mockHelpers'; import { remoteFeatureFlagTrendingTokensEnabled, remoteFeatureFlagPredictEnabled, -} from '../../../tests/api-mocking/mock-responses/feature-flags-mocks'; +} from '../../api-mocking/mock-responses/feature-flags-mocks'; describe(SmokeWalletPlatform('Trending Feed View All Navigation'), () => { const testSpecificMock = async (mockServer: Mockttp) => { diff --git a/e2e/specs/trending/trending-search.spec.ts b/tests/smoke/trending/trending-search.spec.ts similarity index 73% rename from e2e/specs/trending/trending-search.spec.ts rename to tests/smoke/trending/trending-search.spec.ts index 89c31e2812d..6267c050b70 100644 --- a/e2e/specs/trending/trending-search.spec.ts +++ b/tests/smoke/trending/trending-search.spec.ts @@ -1,15 +1,15 @@ -import { SmokeWalletPlatform } from '../../tags'; -import { loginToApp } from '../../viewHelper'; -import FixtureBuilder from '../../../tests/framework/fixtures/FixtureBuilder'; -import { withFixtures } from '../../../tests/framework/fixtures/FixtureHelper'; -import { setupRemoteFeatureFlagsMock } from '../../../tests/api-mocking/helpers/remoteFeatureFlagsHelper'; +import { SmokeWalletPlatform } from '../../../e2e/tags'; +import { loginToApp } from '../../../e2e/viewHelper'; +import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; +import { withFixtures } from '../../framework/fixtures/FixtureHelper'; +import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; import { Mockttp } from 'mockttp'; -import { Assertions } from '../../../tests/framework'; -import TrendingView from '../../../tests/page-objects/Trending/TrendingView'; -import { TRENDING_API_MOCKS } from '../../../tests/api-mocking/mock-responses/trending-api-mocks'; -import { setupMockEvents } from '../../../tests/api-mocking/helpers/mockHelpers'; -import { remoteFeatureFlagTrendingTokensEnabled } from '../../../tests/api-mocking/mock-responses/feature-flags-mocks'; -import TabBarComponent from '../../pages/wallet/TabBarComponent'; +import { Assertions } from '../../framework'; +import TrendingView from '../../page-objects/Trending/TrendingView'; +import { TRENDING_API_MOCKS } from '../../api-mocking/mock-responses/trending-api-mocks'; +import { setupMockEvents } from '../../api-mocking/helpers/mockHelpers'; +import { remoteFeatureFlagTrendingTokensEnabled } from '../../api-mocking/mock-responses/feature-flags-mocks'; +import TabBarComponent from '../../../e2e/pages/wallet/TabBarComponent'; describe(SmokeWalletPlatform('Trending Search Smoke Test'), () => { const testSpecificMock = async (mockServer: Mockttp) => { diff --git a/e2e/specs/wallet/helpers/musd-fixture.ts b/tests/smoke/wallet/helpers/musd-fixture.ts similarity index 86% rename from e2e/specs/wallet/helpers/musd-fixture.ts rename to tests/smoke/wallet/helpers/musd-fixture.ts index 95192da67d4..62b59762a24 100644 --- a/e2e/specs/wallet/helpers/musd-fixture.ts +++ b/tests/smoke/wallet/helpers/musd-fixture.ts @@ -1,14 +1,11 @@ import FixtureBuilder, { type MusdFixtureOptions, -} from '../../../../tests/framework/fixtures/FixtureBuilder'; +} from '../../../framework/fixtures/FixtureBuilder'; import { CHAIN_IDS } from '@metamask/transaction-controller'; import { toChecksumHexAddress } from '@metamask/controller-utils'; -import { AnvilPort } from '../../../../tests/framework/fixtures/FixtureUtils'; -import { AnvilManager } from '../../../../tests/seeder/anvil-manager'; -import { - USDC_MAINNET, - MUSD_MAINNET, -} from '../../../../tests/constants/musd-mainnet'; +import { AnvilPort } from '../../../framework/fixtures/FixtureUtils'; +import { AnvilManager } from '../../../seeder/anvil-manager'; +import { USDC_MAINNET, MUSD_MAINNET } from '../../../constants/musd-mainnet'; const USDC_DECIMALS = 6; const MUSD_DECIMALS = 6; diff --git a/e2e/specs/wallet/musd-conversion-happy-path.spec.ts b/tests/smoke/wallet/musd-conversion-happy-path.spec.ts similarity index 89% rename from e2e/specs/wallet/musd-conversion-happy-path.spec.ts rename to tests/smoke/wallet/musd-conversion-happy-path.spec.ts index 9f9092a4de5..8ab48e18d06 100644 --- a/e2e/specs/wallet/musd-conversion-happy-path.spec.ts +++ b/tests/smoke/wallet/musd-conversion-happy-path.spec.ts @@ -1,20 +1,20 @@ -import { SmokeWalletPlatform } from '../../tags'; -import TestHelpers from '../../helpers'; -import WalletView from '../../pages/wallet/WalletView'; -import { loginToApp } from '../../viewHelper'; -import Assertions from '../../../tests/framework/Assertions'; -import { withFixtures } from '../../../tests/framework/fixtures/FixtureHelper'; +import { SmokeWalletPlatform } from '../../../e2e/tags'; +import TestHelpers from '../../../e2e/helpers'; +import WalletView from '../../../e2e/pages/wallet/WalletView'; +import { loginToApp } from '../../../e2e/viewHelper'; +import Assertions from '../../framework/Assertions'; +import { withFixtures } from '../../framework/fixtures/FixtureHelper'; import { LocalNode, LocalNodeType, type WithFixturesOptions, -} from '../../../tests/framework/types'; -import { AnvilManager } from '../../../tests/seeder/anvil-manager'; -import TransactionPayConfirmation from '../../pages/Confirmation/TransactionPayConfirmation'; -import FooterActions from '../../pages/Browser/Confirmations/FooterActions'; -import TabBarComponent from '../../pages/wallet/TabBarComponent'; -import ActivitiesView from '../../pages/Transactions/ActivitiesView'; -import { setupMusdMocks } from '../../../tests/api-mocking/mock-responses/musd/musd-mocks'; +} from '../../framework/types'; +import { AnvilManager } from '../../seeder/anvil-manager'; +import TransactionPayConfirmation from '../../../e2e/pages/Confirmation/TransactionPayConfirmation'; +import FooterActions from '../../../e2e/pages/Browser/Confirmations/FooterActions'; +import TabBarComponent from '../../../e2e/pages/wallet/TabBarComponent'; +import ActivitiesView from '../../../e2e/pages/Transactions/ActivitiesView'; +import { setupMusdMocks } from '../../api-mocking/mock-responses/musd/musd-mocks'; import { createMusdFixture, type MusdFixtureOptions, diff --git a/yarn.lock b/yarn.lock index 5925bef3176..cc4ec44f67e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7479,62 +7479,6 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controllers@npm:^98.0.0": - version: 98.0.0 - resolution: "@metamask/assets-controllers@npm:98.0.0" - dependencies: - "@ethereumjs/util": "npm:^9.1.0" - "@ethersproject/abi": "npm:^5.7.0" - "@ethersproject/address": "npm:^5.7.0" - "@ethersproject/bignumber": "npm:^5.7.0" - "@ethersproject/contracts": "npm:^5.7.0" - "@ethersproject/providers": "npm:^5.7.0" - "@metamask/abi-utils": "npm:^2.0.3" - "@metamask/account-tree-controller": "npm:^4.0.0" - "@metamask/accounts-controller": "npm:^35.0.2" - "@metamask/approval-controller": "npm:^8.0.0" - "@metamask/base-controller": "npm:^9.0.0" - "@metamask/contract-metadata": "npm:^2.4.0" - "@metamask/controller-utils": "npm:^11.18.0" - "@metamask/core-backend": "npm:^5.0.0" - "@metamask/eth-query": "npm:^4.0.0" - "@metamask/keyring-api": "npm:^21.0.0" - "@metamask/keyring-controller": "npm:^25.1.0" - "@metamask/messenger": "npm:^0.3.0" - "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/multichain-account-service": "npm:^5.1.0" - "@metamask/network-controller": "npm:^29.0.0" - "@metamask/network-enablement-controller": "npm:^4.1.0" - "@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/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.9.2" - "@metamask/utils": "npm:^11.9.0" - "@types/bn.js": "npm:^5.1.5" - "@types/uuid": "npm:^8.3.0" - async-mutex: "npm:^0.5.0" - bitcoin-address-validation: "npm:^2.2.3" - bn.js: "npm:^5.2.1" - immer: "npm:^9.0.6" - lodash: "npm:^4.17.21" - multiformats: "npm:^9.9.0" - reselect: "npm:^5.1.1" - single-call-balance-checker-abi: "npm:^1.0.0" - uuid: "npm:^8.3.2" - peerDependencies: - "@metamask/providers": ^22.0.0 - webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/a2a3564ae4cb5349a134c40844db62db43d8aefa7ac00c8d6a31ace63573b6ea2a77988e1daae7719a99a79736f4dee3af7886d85829cd2efd68dd55b55fb5f6 - 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" @@ -9127,14 +9071,14 @@ __metadata: languageName: node linkType: hard -"@metamask/ramps-controller@npm:^4.1.0": - version: 4.1.0 - resolution: "@metamask/ramps-controller@npm:4.1.0" +"@metamask/ramps-controller@npm:^5.0.0": + version: 5.0.0 + resolution: "@metamask/ramps-controller@npm:5.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/6e05fc1e2765aa69b3667dab5243113c062cf848f67be090c61909c596ed19ba458abe1918c285a043a286a49dde953729dc266cc367aa0aee9718e30f88fec0 + checksum: 10/896a246f7b9b7c7f5029afcacbc56a659b3740520518d6cec98752bf292fe94a4cb3374e2e7b8f9d0ece51b30c33a62ca616422dcc0785257cf6ca5a134d76af languageName: node linkType: hard @@ -34712,7 +34656,7 @@ __metadata: "@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:^98.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" @@ -34783,7 +34727,7 @@ __metadata: "@metamask/profile-metrics-controller": "npm:^2.0.0" "@metamask/profile-sync-controller": "npm:^27.0.0" "@metamask/providers": "npm:^18.3.1" - "@metamask/ramps-controller": "npm:^4.1.0" + "@metamask/ramps-controller": "npm:^5.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"