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 @@
-